Skip to content

feat(stf): zero-alloc processSyncAggregate - #269

Draft
spiral-ladder wants to merge 2 commits into
mainfrom
bing/zero-alloc-process-sync-agg
Draft

feat(stf): zero-alloc processSyncAggregate#269
spiral-ladder wants to merge 2 commits into
mainfrom
bing/zero-alloc-process-sync-agg

Conversation

@spiral-ladder

Copy link
Copy Markdown
Member

Introduces a zero-alloc processSyncAggregate.

Done by:

separating intersectValues in BitVector into:

  • intersectValuesAlloc: the original intersectValues.
  • intersectValues: a new function that takes a out: [length]T as a param.

The maximum size of the BitVector is 512, which is negligible on the
stack. We introduce an alloc-free version as a minor optimization.

Then since we know the pubkeys are going to be 512 * 96 = 48KiB worth of allocations,
we can skip heap allocations and simply use an array for participant_buf.

separate `intersectValues` into:

- `intersectValuesAlloc`: the original `intersectValues`.
- `intersectValues`: a new function that takes a `out: [length]T` as a param.

The maximum size of a `BitVector` is 512, which is negligible on the
stack. We introduce an alloc-free version as a minor optimization.
We also alloc 512 * 96 = 48KiB worth of pubkeys previously on the heap,
which we now switch to use arrays since that amount seems reasonable.
@spiral-ladder spiral-ladder self-assigned this Mar 25, 2026
@spiral-ladder spiral-ladder changed the title feat(stf): zero-alloc processSyncAggregate feat(stf): zero-alloc processSyncAggregate Mar 25, 2026
@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 optimizes the processSyncAggregate function by eliminating dynamic memory allocations. This was achieved by introducing a new zero-allocation method for intersecting bit vector values and utilizing stack-allocated buffers for participant indices and public keys, resulting in a more efficient and predictable memory footprint for this critical state transition operation.

Highlights

  • Zero-Allocation processSyncAggregate: The processSyncAggregate function has been refactored to avoid heap allocations, making it a zero-allocation operation for minor performance optimization.
  • BitVector intersectValues Refactoring: The BitVector type's intersectValues function was split into intersectValuesAlloc (the original allocating version) and a new intersectValues that takes an output buffer, enabling callers to manage memory directly.
  • Stack-Allocated Buffers: Heap allocations for participant_buf and pubkeys_buf within processSyncAggregate have been replaced with stack-allocated arrays, leveraging the known maximum size of BitVector (512) to avoid dynamic memory management.

🧠 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.

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.

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.

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

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.

Code Review

This pull request refactors the BitVector.intersectValues function by introducing a new non-allocating version that takes an output buffer, while renaming the original allocating version to intersectValuesAlloc. This change is propagated to the processSyncAggregate function and its callers, including benchmarks and tests, to eliminate dynamic memory allocations by utilizing stack-allocated buffers for participant indices and public keys. Review comments suggest adding null pointer assertions to the new intersectValues function's arguments, changing usize variables to explicitly-sized types like u32 within the same function, and minimizing the scope of participant_buf in getSyncCommitteeSignatureSet by declaring it within its else block, all to align with the project's style guide.

Comment on lines +153 to +158
pub fn intersectValues(
self: *const @This(),
comptime T: type,
values: *const [length]T,
out: *[length]T,
) []T {

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

According to the style guide (rule 51), all function arguments should be asserted. This function is missing checks for its pointer arguments (self, values, out).

Please add assertions at the beginning of the function body to ensure these pointers are not null. This improves safety by catching potential null pointer dereferences early.

References
  1. Assert all function arguments and return values, pre/postconditions and invariants. A function must not operate blindly on data it has not checked. The purpose of a function is to increase the probability that a program is correct. Assertions within a function are part of how functions serve this purpose. The assertion density of the code must average a minimum of two assertions per function. (link)

Comment on lines +159 to +171
var i: usize = 0;
for (0..byte_len) |i_byte| {
var b = self.data[i_byte];
// Kernighan's algorithm to count the set bits instead of going through 0..8 for every byte
while (b != 0) {
const lsb: usize = @as(u8, @ctz(b)); // Get the index of least significant bit
const bit_index = i_byte * 8 + lsb;
out[i] = values[bit_index];
i += 1;
// The `b - 1` flips the bits starting from `lsb` index
// And `&` will reset the last bit at `lsb` index
b &= b - 1;
}

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 (rule 44) recommends avoiding usize in favor of explicitly-sized integer types like u32 to improve portability and explicitness. The variables i, lsb, and bit_index are currently usize.

Please consider changing them to a fixed-size type like u32.

            var i: u32 = 0;
            for (0..byte_len) |i_byte| {
                var b = self.data[i_byte];
                // Kernighan's algorithm to count the set bits instead of going through 0..8 for every byte
                while (b != 0) {
                    const lsb: u32 = @ctz(b); // Get the index of least significant bit
                    const bit_index: u32 = @as(u32, i_byte) * 8 + lsb;
                    out[i] = values[bit_index];
                    i += 1;
                    // The `b - 1` flips the bits starting from `lsb` index
                    // And `&` will reset the last bit at `lsb` index
                    b &= b - 1;
                }

References
  1. Use explicitly-sized types like u32 for everything, avoid architecture-specific usize. (link)

Comment on lines +125 to 129
var participant_buf: [preset.SYNC_COMMITTEE_SIZE]ValidatorIndex = undefined;
const participant_indices_ = if (participant_indices) |pi| pi else blk: {
const committee_indices = @as(*const [preset.SYNC_COMMITTEE_SIZE]u64, @ptrCast(epoch_cache.current_sync_committee_indexed.get().getValidatorIndices()));
break :blk (try sync_aggregate.sync_committee_bits.intersectValues(ValidatorIndex, allocator, committee_indices)).items;
break :blk sync_aggregate.sync_committee_bits.intersectValues(ValidatorIndex, committee_indices, &participant_buf);
};

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

To minimize the scope of participant_buf and adhere to style guide rules 104 and 356, it should be declared inside the else block where it is used.

    const participant_indices_ = if (participant_indices) |pi| pi else blk: {
        const committee_indices = @as(*const [preset.SYNC_COMMITTEE_SIZE]u64, @ptrCast(epoch_cache.current_sync_committee_indexed.get().getValidatorIndices()));
        var participant_buf: [preset.SYNC_COMMITTEE_SIZE]ValidatorIndex = undefined;
        break :blk sync_aggregate.sync_committee_bits.intersectValues(ValidatorIndex, committee_indices, &participant_buf);
    };
References
  1. Declare variables at the smallest possible scope, and minimize the number of variables in scope, to reduce the probability that variables are misused. (link)
  2. Calculate or check variables close to where/when they are used. Don't introduce variables before they are needed. Don't leave them around where they are not. (link)

@lodekeeper-z lodekeeper-z left a comment

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.

Clean optimization — stack-allocated buffers for sync aggregate processing. The new intersectValues correctly mirrors the existing Kernighan's algorithm logic.

One minor observation: getSyncCommitteeSignatureSet still heap-allocates pubkeys (line 161) — could be stack-allocated too for consistency, but fine as a follow-up.

48KB total stack usage (4KB indices + ~24KB pubkeys in processSyncAggregate, + 4KB in getSyncCommitteeSignatureSet) is well within safe limits.

@lodekeeper-z lodekeeper-z left a comment

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.

CI is failing — there's a second processSyncAggregate call site in bench/state_transition/process_block.zig:421 that still passes allocator. Needs the same fix as the one at line 218.

bench/state_transition/process_block.zig:421:33: error: expected 6 argument(s), found 7

Also: getSyncCommitteeSignatureSet still takes allocator as a param (line 117 in the diff) but no longer uses it for intersectValues — worth removing it from that function's signature too for consistency? Though it might still be used for other things in that function, haven't checked.

@lodekeeper-z

Copy link
Copy Markdown
Contributor

CI is failing because bench/state_transition/process_block.zig:421 still calls processSyncAggregate with the old 7-arg signature. The function now takes 6 args after the zero-alloc change — needs the allocator arg removed from the benchmark call site too.

@lodekeeper-z lodekeeper-z left a comment

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.

CI is failing — there's a second processSyncAggregate call site in bench/state_transition/process_block.zig:421 that still passes allocator. The one at line 215 was updated but this one was missed.

-                state_transition.processSyncAggregate(
-                    fork,
-                    allocator,
-                    cloned.config,
+                state_transition.processSyncAggregate(
+                    fork,
+                    cloned.config,

Otherwise the PR looks good — nice zero-alloc optimization 👍

@lodekeeper-z lodekeeper-z left a comment

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.

Clean change. The zero-alloc approach with stack buffers is correct — SYNC_COMMITTEE_SIZE is compile-time known and fits comfortably on the stack.

Nice side-effect: this also fixes a memory leak in getSyncCommitteeSignatureSet where the old intersectValues returned an ArrayList whose .items was extracted but the list itself was never deinit'd.

Kernighan's bit iteration in the new intersectValues matches the existing allocating version — consistent behavior. 👍

@lodekeeper-z lodekeeper-z left a comment

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.

Clean PR. Stack-allocated buffers for sync committee processing — eliminates heap allocations for both participant_indices and pubkeys.

Nice side-effect: fixes a memory leak in getSyncCommitteeSignatureSet where the old code did (try ...intersectValues(...)).items which took the backing slice but never deinitialized the ArrayList.

Kernighan's bit trick in the new intersectValues is a good touch — skips zero bytes efficiently.

LGTM ✅

@lodekeeper-z

Copy link
Copy Markdown
Contributor

CI is failing on all 3 platforms — the bench file bench/state_transition/process_block.zig:421 still calls processSyncAggregate with 7 args (now expects 6 after your refactor). Looks like just the benchmark needs updating to match the new signature.

@lodekeeper-z

Copy link
Copy Markdown
Contributor

Hey @spiral-ladder — looks like this needs a rebase against main. CI is failing on all 3 build targets (ubuntu, arm, macos). The base is from a351e0e which is well behind current main (f684a9f). Several merged PRs have changed APIs this might depend on. Let me know if you'd like help with the rebase.

@matthewkeil

Copy link
Copy Markdown
Member

@spiral-ladder is this still relevant with all the other great work youve done on blst?

@spiral-ladder

Copy link
Copy Markdown
Member Author

@matthewkeil i think it is but low prio, since it's just an optimization for state transition

one thing to discuss here is if its worth the change in the ssz api for this

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants