Skip to content

feat: add StructContainerTreeView, model Validator as StructContainerType#247

Open
wemeetagain wants to merge 2 commits intomainfrom
cayman/struct-container-cleanup
Open

feat: add StructContainerTreeView, model Validator as StructContainerType#247
wemeetagain wants to merge 2 commits intomainfrom
cayman/struct-container-cleanup

Conversation

@wemeetagain
Copy link
Copy Markdown
Member

Summary

Replaces PR #232. Adds StructContainerTreeView — a view-level optimization for fixed-size containers that stores the value as a flat Zig struct instead of decomposing it into the merkle tree.

The optimization: fields are read and written in O(1) via direct struct field access. Dirty fields are tracked with a StaticBitSet. On commit, only changed fields are spliced into the existing merkle tree via setNodesAtDepth — changing 1 field out of 8 creates ~3 pool nodes instead of ~15. serializeIntoBytes and toValue read directly from the struct without committing.

The pool stays pure. No special node types, no type erasure, no pointer stuffing. This is entirely a view-layer optimization. The tree in the pool is a normal merkle tree built by FixedContainerType.tree.fromValue.

Design

  • StructContainerTreeView stores value: T (owned flat struct) + changed: StaticBitSet(N) (1 byte for Validator's 8 fields)
  • get is a single @field(self.value, name) — no tree traversal, no optional unwrap
  • set writes to the struct and sets a bit — skips if value unchanged
  • commit iterates set bits, creates leaf nodes only for dirty fields, splices via setNodesAtDepth
  • StructContainerType is FixedContainerTypeWith(ST, .struct_container) — 3 lines, no delegation boilerplate

What changed

File Change
src/ssz/type/container.zig Parameterize FixedContainerType with TreeViewType enum; add StructContainerType
src/ssz/tree_view/container.zig Add StructContainerTreeView with tests
src/consensus_types/phase0.zig Validator → StructContainerType
src/state_transition/test_utils/generate_state.zig Validator field access uses struct semantics

Test plan

  • zig build test:ssz — all tests pass including:
    • Basic get/set/commit roundtrip
    • Partial update produces correct merkle root
    • Clone isolates changes
    • Repeated commits don't leak pool nodes (tight 64-slot pool)
    • Double commit is idempotent
    • Deinit with uncommitted changes
    • fromValue roundtrip
  • zig build test:spec_tests -Dpreset=minimal

🤖 Generated with Claude Code

@wemeetagain wemeetagain requested a review from a team as a code owner March 17, 2026 21:29
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a significant performance enhancement for handling fixed-size containers within the SSZ (Simple Serialize) library. By implementing StructContainerTreeView, the system can now store and access container fields directly as a flat Zig struct, leading to constant-time field operations. This also optimizes Merkle tree updates by only processing fields that have actually changed, thereby reducing computational overhead and memory allocations. The Validator type has been updated to utilize this new, more efficient container type.

Highlights

  • New StructContainerTreeView: Introduced StructContainerTreeView as a view-level optimization for fixed-size containers, storing values as flat Zig structs for O(1) field access.
  • Optimized Commit Mechanism: The StructContainerTreeView efficiently commits changes by tracking dirty fields with a StaticBitSet and only splicing changed fields into the Merkle tree, significantly reducing node creation.
  • Validator Model Update: The Validator type in phase0.zig has been updated to use the new StructContainerType, leveraging the performance benefits of StructContainerTreeView.
  • Pure Pool Integration: The optimization is entirely at the view layer, ensuring the underlying Merkle tree in the pool remains pure with no special node types or type erasure.
  • Refactored Container Types: The FixedContainerType was refactored to use a parameterized FixedContainerTypeWith function, allowing selection between ContainerTreeView and StructContainerTreeView.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/consensus_types/phase0.zig
    • Changed Validator type definition from FixedContainerType to StructContainerType.
  • src/ssz/root.zig
    • Exported StructContainerType from types.
    • Exported StructContainerTreeView from tree_view.
  • src/ssz/tree_view/container.zig
    • Added the StructContainerTreeView implementation, providing optimized view-level operations for fixed-size containers.
    • Included comprehensive tests for StructContainerTreeView covering various scenarios like partial updates, cloning, and node management.
    • Added necessary imports for StructContainerType, UintType, and BoolType to support the new tests.
  • src/ssz/tree_view/root.zig
    • Added StructContainerTreeView to the public exports.
  • src/ssz/type/container.zig
    • Imported StructContainerTreeView.
    • Introduced TreeViewType enum to specify the type of container view.
    • Refactored FixedContainerType to delegate to a new FixedContainerTypeWith function.
    • Updated the TreeView type within FixedContainerTypeWith to dynamically select between ContainerTreeView and StructContainerTreeView.
    • Added StructContainerType as a public function utilizing FixedContainerTypeWith with the struct_container view type.
  • src/ssz/type/root.zig
    • Exported StructContainerType from container.zig.
  • src/state_transition/test_utils/generate_state.zig
    • Updated pubkey field access on validator objects to align with the new StructContainerType semantics.
Activity
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces StructContainerTreeView, a significant performance optimization for fixed-size containers like Validator, by caching the value as a struct. The implementation is well-structured and includes a comprehensive set of tests. My review focuses on improving adherence to the repository's style guide and fixing a minor issue in the clone implementation. Specifically, I've suggested adding assertions for increased safety, correcting the behavior of clone to respect its options, and wrapping long lines to maintain code readability.

Comment on lines +462 to +478
pub fn clone(self: *Self, opts: CloneOpts) !*Self {
_ = opts;
try self.commit();

try self.pool.ref(self.root);
errdefer self.pool.unref(self.root);

const ptr = try self.allocator.create(Self);
ptr.* = .{
.allocator = self.allocator,
.pool = self.pool,
.root = self.root,
.value = self.value,
.changed = std.StaticBitSet(ST.chunk_count).initEmpty(),
};
return ptr;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The clone function takes an opts: CloneOpts parameter but then ignores it. The transfer_cache option should be respected. When transfer_cache is false, the value should be re-decoded from the merkle tree root, similar to how it's done in init. The current implementation always transfers the cached value, effectively ignoring opts.transfer_cache = false.

        pub fn clone(self: *Self, opts: CloneOpts) !*Self {
            try self.commit();

            try self.pool.ref(self.root);
            errdefer self.pool.unref(self.root);

            const ptr = try self.allocator.create(Self);
            errdefer self.allocator.destroy(ptr);

            ptr.allocator = self.allocator;
            ptr.pool = self.pool;
            ptr.root = self.root;
            ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty();

            if (opts.transfer_cache) {
                ptr.value = self.value;
            } else {
                try ST.tree.toValue(self.root, self.pool, &ptr.value);
            }

            return ptr;
        }

Comment on lines +444 to +458
pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self {
try pool.ref(root);
errdefer pool.unref(root);

const ptr = try allocator.create(Self);
errdefer allocator.destroy(ptr);

try ST.tree.toValue(root, pool, &ptr.value);

ptr.allocator = allocator;
ptr.pool = pool;
ptr.root = root;
ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty();
return ptr;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The style guide (lines 51-54) mandates asserting all function arguments, preconditions, and postconditions to improve safety. This function, and others in StructContainerTreeView, are missing assertions. For example, you could assert that pool is not null upon entry and that the returned pointer's fields are correctly initialized upon exit.

        pub fn init(allocator: Allocator, pool: *Node.Pool, root: Node.Id) !*Self {
            std.debug.assert(pool != null);

            try pool.ref(root);
            errdefer pool.unref(root);

            const ptr = try allocator.create(Self);
            errdefer allocator.destroy(ptr);

            try ST.tree.toValue(root, pool, &ptr.value);

            ptr.allocator = allocator;
            ptr.pool = pool;
            ptr.root = root;
            ptr.changed = std.StaticBitSet(ST.chunk_count).initEmpty();
            return ptr;
        }
References
  1. Functions must assert all arguments, return values, preconditions, postconditions, and invariants. The assertion density should be at least two assertions per function. (link)

inline for (ST.fields, 0..) |field, i| {
if (self.changed.isSet(i)) {
const ChildST = ST.getFieldType(field.name);
nodes[changed_idx] = try ChildST.tree.fromValue(self.pool, &@field(self.value, field.name));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line exceeds the 100-column limit specified in the style guide (line 400). Extracting the field value into a local variable would improve readability and resolve the line length issue.

                    const field_value = &@field(self.value, field.name);
                    nodes[changed_idx] = try ChildST.tree.fromValue(self.pool, field_value);
References
  1. Lines must not exceed 100 columns to ensure code is readable without horizontal scrolling. (link)

// Phase 2: Splice nodes into the tree.
// setNodesAtDepth refs each node via rebind (refcount 0 → 1, tree-owned).
// On success, the tree owns the only ref — do NOT free the nodes.
const new_root = try self.root.setNodesAtDepth(self.pool, ST.chunk_depth, indices[0..changed_idx], nodes[0..changed_idx]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line exceeds the 100-column limit specified in the style guide (line 400). Please wrap the function call for better readability.

            const new_root = try self.root.setNodesAtDepth(
                self.pool,
                ST.chunk_depth,
                indices[0..changed_idx],
                nodes[0..changed_idx],
            );
References
  1. Lines must not exceed 100 columns to ensure code is readable without horizontal scrolling. (link)

Comment on lines +573 to +575
if (std.mem.eql(u8, std.mem.asBytes(&@field(self.value, field_name)), std.mem.asBytes(&value))) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line exceeds the 100-column limit specified in the style guide (line 400). Extracting the byte slices into local variables would make the code more readable and fix the line length.

            const current_bytes = std.mem.asBytes(&@field(self.value, field_name));
            const new_bytes = std.mem.asBytes(&value);
            if (std.mem.eql(u8, current_bytes, new_bytes)) {
                return;
            }
References
  1. Lines must not exceed 100 columns to ensure code is readable without horizontal scrolling. (link)

@wemeetagain wemeetagain force-pushed the cayman/struct-container-cleanup branch 2 times, most recently from 2a30ec4 to 33ed725 Compare March 18, 2026 00:53
…Type

Add StructContainerTreeView — a tree view for fixed-size containers
that stores the value as a flat Zig struct. Fields are read and written
in O(1) via direct struct access. Dirty fields are tracked with a
StaticBitSet. On commit, only changed fields are spliced into the
merkle tree via setNodesAtDepth. The pool has no special node types —
this is purely a view-level optimization.

- Add StructContainerTreeView with StaticBitSet dirty tracking
- Parameterize FixedContainerType with TreeViewType enum
- StructContainerType selects StructContainerTreeView via the enum
- Model phase0 Validator as StructContainerType
- set() skips write and dirty-marking when value is unchanged
- serializeIntoBytes/toValue read from the struct directly (no commit needed)
- commit has clear ownership phases with correct errdefer cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@wemeetagain wemeetagain force-pushed the cayman/struct-container-cleanup branch from 33ed725 to 458802d Compare March 18, 2026 01:00
Comment thread src/ssz/tree_view/container.zig Outdated
changed_idx = 0; // disarm errdefer — nodes are now tree-owned

// Phase 3: Update root ownership.
errdefer self.pool.unref(new_root);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

errdefer should be below the next try self.pool.ref(new_root); when we're sure it's ref successfully
but then there is no try after that
so seems like a redundant errdefer here

Comment thread src/ssz/tree_view/container.zig Outdated
pub fn fromValue(allocator: Allocator, pool: *Node.Pool, value: *const ST.Type) !*Self {
// tree.fromValue returns root at refcount 0. Use init which refs it.
const root = try ST.tree.fromValue(pool, value);
const ptr = try Self.init(allocator, pool, root);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that this call will extract value from root again but not sure how to improve it
the TreeView.init() is a contract anyway

Copy link
Copy Markdown
Collaborator

@twoeths twoeths Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe inline the whole logic of init here, similar to what we've done in clone() above?

Copy link
Copy Markdown
Collaborator

@twoeths twoeths left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wemeetagain the performance of before_process_epoch is almost the same to main
this is due to having to deserialize value from a node

try ST.Element.tree.toValue(node, self.state.pool, &values[i]);

while with #232 you just get it from the pointer of node https://github.com/ChainSafe/lodestar-z/pull/232/changes#diff-2320e93e35c98f65b4cef2c729d8729fda9c280365baa569c2bf6c961230d745R839

- clone() respects transfer_cache option
- fromValue() inlines init logic to skip redundant tree readback
- Remove redundant errdefer in commit() after pool.ref

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown
Contributor

@lodekeeper-z lodekeeper-z left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: feat: add StructContainerTreeView, model Validator as StructContainerType

Clean design. The core insight — caching the full struct value alongside the merkle tree and using a StaticBitSet dirty tracker to splice only changed fields on commit — gives O(1) field access without touching the pool's node model. This is a strictly view-level optimization: no new node types, no pointer encoding in left/right slots, no BranchStructRef indirection. That's a meaningful simplification over the competing approach in #232.

What's good

  1. Zero pool coupling. The pool doesn't know or care about StructContainerTreeView. It sees normal leaf/branch nodes. This means every existing operation — setNodesAtDepth, getNodeAtDepth, clone, serialization — works unchanged. No new node lifecycle to reason about. No type-erased vtable stored in pointer-encoded left/right slots.

  2. StructContainerType via TreeViewType enum. The parameterization of FixedContainerTypeWith to select between ContainerTreeView and StructContainerTreeView is minimal and clean. The SSZ type layer is unchanged — StructContainerType delegates everything to FixedContainerType except which tree view to use.

  3. Commit is well-structured. The three-phase approach (create leaf nodes → splice into tree → update root ownership) with errdefer cleanup and the changed_idx = 0 disarm trick is correct. The ownership chain is clear: nodes start at refcount 0, setNodesAtDepth takes ownership via rebind, then we ref the new root and unref the old.

  4. set() skip-if-unchanged. The byte-level equality check in set() avoids dirtying unchanged fields. Smart for process_epoch where many validators get "written" with the same value.

  5. Test suite. Covers the important cases — partial update correctness, clone isolation, pool node leak detection (tight pool test), double-commit idempotency, deinit with uncommitted changes, fromValue roundtrip.

Observations

1. toValue skips commit — is this intentional?

pub fn toValue(self: *Self, allocator: Allocator, out: *ST.Type) !void {
    _ = allocator;
    out.* = self.value;
}

This returns the in-memory value directly, which includes uncommitted changes (since set() writes to self.value immediately). That's actually fine — the struct is the source of truth, and uncommitted just means the tree hasn't been updated yet. But serializeIntoBytes also reads from self.value without committing, so serialization of a view with uncommitted changes gives the dirty value, not the tree's value. This is consistent but worth a doc comment clarifying that toValue/serializeIntoBytes reflect uncommitted state while hashTreeRoot/getFieldRoot commit first.

2. Memory size per view: ~sizeof(T) + bitset overhead

For Validator (121 bytes as SSZ, struct is 129 bytes with Zig padding), each StructContainerTreeView carries the full struct. With ~1M validators in a ListCompositeTreeView, how does this interact? Each get() call on the list creates a new StructContainerTreeView (via init) which does toValue (reads 8 fields from the tree). If process_epoch accesses many validators, we're paying for the tree readback on every list access.

I suspect the real perf win comes from combining this with twoeths's observation: skipping the per-field deserialization by reading directly from a branch_struct node. Your approach is a stepping stone — the view API is right, but the tree-backed init path will need the pointer-in-node optimization from #232 for the hot loop. Is that the intended roadmap? #244 ("Cayman/232 review") suggests you're working on reconciling the two approaches.

3. fromValue double-builds the tree

The inlined fromValue builds the full merkle tree via ST.tree.fromValue(pool, value) (creating all 8 leaf nodes + branch nodes), then stores value.* in the struct. On the next commit(), if any field is dirty, it creates new leaf nodes and splices them in. The initial tree nodes for those fields are then unreachable and freed. Not a bug, but if fromValue is followed immediately by mutations (common pattern: create validator, set fields), the initial tree work for the mutated fields is wasted. An optimization for later: fromValue could skip tree building entirely and defer it to the first commit().

4. Devil's advocate: when does this approach lose to #232?

#232 stores the struct pointer directly in the node pool (via branch_struct node type), so any code that navigates the tree to the validator node gets O(1) access to the struct without creating a view. In your approach, every access requires instantiating a StructContainerTreeView, doing the tree readback, and heap-allocating the view. For one-off reads ("get validator N's effective balance"), #232 wins.

Your approach wins on simplicity, correctness, and non-invasiveness. The pool stays clean. But for process_epoch with ~1M validators, the per-access overhead matters. The two approaches may actually compose: use #232's branch_struct for storage + your StructContainerTreeView API for mutations. Worth discussing.

Nits

  • Several lines exceed 100 columns (the set() byte comparison, commit() field iteration). Gemini flagged these — worth wrapping for style guide compliance.
  • toValue ignores allocator param. Consider documenting why (or removing it if the interface allows — though assertTreeViewType probably requires it).

vs #232

For anyone following both PRs: #247 is the simpler, safer approach (view-level only, no pool changes). #232 is more invasive (new branch_struct node type, pointer encoding, type-erased vtable in the pool) but gives O(1) access without view instantiation. twoeths's benchmark comment on #247 suggests performance is "almost the same as main" for before_process_epoch because the init path still deserializes from the tree — this confirms the readback cost needs addressing regardless of which approach lands.

I'd suggest landing #247 first (it's simpler, correct, well-tested, and the view API is right), then layering #232's branch_struct storage optimization underneath if benchmarks justify it.

Overall: clean, correct, well-tested. 👍

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.

3 participants