Skip to content

feat(bindings/bls): use external buffers for blst operations - #356

Closed
spiral-ladder wants to merge 1 commit into
mainfrom
bing/external-arraybuffers
Closed

feat(bindings/bls): use external buffers for blst operations#356
spiral-ladder wants to merge 1 commit into
mainfrom
bing/external-arraybuffers

Conversation

@spiral-ladder

Copy link
Copy Markdown
Member

This is one of possible likely causes for increased GC pressure on experiments to swap out blst-ts for lodestar-z/bls, as observed on feat2 and feat3 deployments in this PR.

With external array buffers, V8 is only aware of the pointer to the backing memory, instead of having to track both the pointer and the backing memory. This means that during marking phase the GC does not have to walk the backing memory to mark it as 'live' - the frequency of the GC firing off is still the same, but each cycle does less work.

This of course comes with a tradeoff, we need a finalizer to let V8 know how much external memory is in native heap so that the GC tells the native impl to free the useless memory.

Though, regardless of the effect, we should still probably do this anyway, since napi-rs does the same, and only defaults to V8 managed array buffers if it is disallowed (like in Electron).

This is one of possible likely causes for increased GC pressure
on experiments to swap out blst-ts for lodestar-z/bls, as observed
on feat2 and feat3 deployments in [this
PR](ChainSafe/lodestar#9342).

With external array buffers, V8 is only aware of the pointer to the
backing memory, instead of having to track both the pointer and the
backing memory. This means that during marking phase the GC does not
have to walk the backing memory to mark it as 'live' - the frequency
of the GC firing off is still the same, but each cycle does less work.

This of course comes with a tradeoff, we need a
**finalizer** to let V8 know how much external memory is in native heap 
so that the GC tells the native impl to free the useless memory.

Though, regardless of the effect, we should still probably do this
anyway, since [napi-rs does the same](https://github.com/napi-rs/napi-rs/blob/159395b365c583a6642ad481edc5708d9f36a24b/crates/napi/src/bindgen_runtime/js_values/arraybuffer.rs#L175), and only defaults to V8 managed
array buffers if it is disallowed (like in Electron).
@spiral-ladder spiral-ladder self-assigned this May 12, 2026
@spiral-ladder
spiral-ladder requested a review from a team as a code owner May 12, 2026 05:53
@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 memory management for blst operations by transitioning from V8-managed array buffers to external array buffers. By moving the backing memory to the native heap, the garbage collector no longer needs to traverse these buffers during marking phases, which helps mitigate GC pressure in high-throughput environments. A native finalizer has been added to ensure proper memory cleanup, maintaining safety while improving performance.

Highlights

  • External Array Buffers: Introduced createExternalUint8Array to manage memory outside of V8's heap, reducing GC pressure by decoupling the backing memory from V8's object tracking.
  • Memory Management: Implemented a native finalizer to ensure memory allocated for external buffers is correctly freed when the corresponding JS object is garbage collected.
  • Refactoring: Updated PublicKey, Signature, and SecretKey serialization methods to utilize the new external buffer helper, simplifying the code and improving performance.
New Features

🧠 You can now enable Memory (public preview) 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 the 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 counterproductive. 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.

@spiral-ladder spiral-ladder changed the title feat(blst): use external buffers for blst operations feat(bindings/bls): use external buffers for blst operations May 12, 2026

@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 introduces a createExternalUint8Array helper and a corresponding finalizer in bindings/napi/blst.zig to manage external memory for JavaScript Uint8Array objects, refactoring the byte conversion logic for public keys, signatures, and secret keys. Feedback focuses on adhering to the repository style guide, specifically regarding the inclusion of assertions for arguments and return values, renaming helper functions for clarity, avoiding architecture-specific types like usize, enforcing line length limits, and ensuring robust error handling.

Comment thread bindings/napi/blst.zig
Comment on lines +150 to +162
fn createExternalUint8Array(env: napi.Env, bytes: []const u8) !napi.Value {
const buf = try allocator.alloc(u8, bytes.len);
@memcpy(buf, bytes);

const len_hint: ?*anyopaque = @ptrFromInt(bytes.len);
const arraybuffer = env.createExternalArrayBuffer(buf, externalUint8Finalize, len_hint) catch |err| {
allocator.free(buf);
return err;
};

_ = try env.adjustExternalMemory(@intCast(bytes.len));
return try env.createTypedarray(.uint8, bytes.len, arraybuffer, 0);
}

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 adhere to the repository style guide (Lines 51-55), this function should include assertions for its arguments and maintain a minimum assertion density of two per function. Additionally, the return value of adjustExternalMemory should be asserted rather than ignored (Line 51). The callback name in the createExternalArrayBuffer call should also be updated to createExternalUint8ArrayFinalize to match the renamed finalizer for better clarity (Line 246).

fn createExternalUint8Array(env: napi.Env, bytes: []const u8) !napi.Value {
    std.debug.assert(bytes.len > 0);
    std.debug.assert(bytes.len <= std.math.maxInt(i64));

    const buf = try allocator.alloc(u8, bytes.len);
    @memcpy(buf, bytes);

    const len_hint: ?*anyopaque = @ptrFromInt(bytes.len);
    const arraybuffer = env.createExternalArrayBuffer(buf, createExternalUint8ArrayFinalize, len_hint) catch |err| {
        allocator.free(buf);
        return err;
    };

    const adjusted = try env.adjustExternalMemory(@intCast(bytes.len));
    std.debug.assert(adjusted >= @as(i64, @intCast(bytes.len)));

    return try env.createTypedarray(.uint8, bytes.len, arraybuffer, 0);
}
References
  1. Assert all function arguments and return values, pre/postconditions and invariants. The assertion density of the code must average a minimum of two assertions per function. (link)
  2. When a single function calls out to a helper function or callback, prefix the name of the helper function with the name of the calling function to show the call history. (link)

Comment thread bindings/napi/blst.zig
Comment on lines +164 to +173
fn externalUint8Finalize(env: napi.c.napi_env, data: ?*anyopaque, hint: ?*anyopaque) callconv(.c) void {
const len: usize = @intFromPtr(hint);
if (data) |d| {
const ptr: [*]u8 = @ptrCast(d);
allocator.free(ptr[0..len]);
}
var result: i64 = undefined;

_ = napi.status.check(napi.c.napi_adjust_external_memory(env, -@as(i64, @intCast(len)), &result)) catch 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

The finalizer should be renamed to createExternalUint8ArrayFinalize to clearly associate it with its caller (Line 246). It also requires assertions for all arguments (Line 51). Furthermore, architecture-specific usize should be avoided in favor of explicitly-sized types like u64 (Line 44), and the long line calling napi_adjust_external_memory should be wrapped to stay within the 100-column limit (Line 400). The error handling should also be more robust than a silent catch (Line 46, 160).

fn createExternalUint8ArrayFinalize(env: napi.c.napi_env, data: ?*anyopaque, hint: ?*anyopaque) callconv(.c) void {
    std.debug.assert(env != null);
    std.debug.assert(data != null);
    std.debug.assert(hint != null);

    const len: u64 = @intCast(@intFromPtr(hint));
    if (data) |d| {
        const ptr: [*]u8 = @ptrCast(d);
        allocator.free(ptr[0..@intCast(len)]);
    }
    var result: i64 = undefined;

    _ = napi.status.check(napi.c.napi_adjust_external_memory(
        env,
        -@as(i64, @intCast(len)),
        &result,
    )) catch {
        std.debug.assert(false);
    };
}
References
  1. Use explicitly-sized types like u32 for everything, avoid architecture-specific usize. (link)
  2. Hard limit all line lengths, without exception, to at most 100 columns. (link)
  3. Assertions detect programmer errors. The only correct way to handle corrupt code is to crash. (link)

Comment thread bindings/napi/blst.zig
Comment on lines +151 to +152
const buf = try allocator.alloc(u8, bytes.len);
@memcpy(buf, bytes);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can use allocator.dupe as a shorthand

Comment thread bindings/napi/blst.zig
return b.getValueBool();
}

/// Wraps `bytes` as a JS `Uint8Array` backed by an *external* (native-heap) ArrayBuffer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd change the comment here to mentioning duping, copying, etc. Current comment sounds like bytes is being captured.

Comment thread bindings/napi/blst.zig

const len_hint: ?*anyopaque = @ptrFromInt(bytes.len);
const arraybuffer = env.createExternalArrayBuffer(buf, externalUint8Finalize, len_hint) catch |err| {
allocator.free(buf);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

you should use an errdefer allocator.free(buf) above, and call env.createExternalArrayBuffer with try.

Comment thread bindings/napi/blst.zig
Comment on lines +170 to +172
var result: i64 = undefined;

_ = napi.status.check(napi.c.napi_adjust_external_memory(env, -@as(i64, @intCast(len)), &result)) catch return {};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

don't need result or napi.status.check since neither are intended to be used.

@spiral-ladder

Copy link
Copy Markdown
Member Author

@wemeetagain thanks for the review! Though this is largely irrelevant now since the layer that handles creating of array buffers is moved to zapi, will open a PR there instead

spiral-ladder added a commit to ChainSafe/zapi that referenced this pull request May 13, 2026
ported from ChainSafe/lodestar-z#356

External array buffers have their lifetimes managed by V8's garbage
collector, but their backing memory is still managed by the native
implementation.

We need to call `adjustExternalMemory` to let V8 know about the
native allocations; and we need a finalizer to cleanup such
allocations (which we add in this PR, and use in lodestar-z)

More details from that PR:

> This is one of possible likely causes for increased GC pressure on experiments to swap out blst-ts for lodestar-z/bls, as observed on feat2 and feat3 deployments in [this PR](ChainSafe/lodestar#9342).
> 
> With external array buffers, V8 is only aware of the pointer to the backing memory, instead of having to track both the pointer and the backing memory. This means that during marking phase the GC does not have to walk the backing memory to mark it as 'live' - the frequency of the GC firing off is still the same, but each cycle does less work.
> 
> This of course comes with a tradeoff, we need a **finalizer** to let V8 know how much external memory is in native heap  so that the GC tells the native impl to free the useless memory.
> 
> Though, regardless of the effect, we should still probably do this anyway, since [napi-rs does the same](https://github.com/napi-rs/napi-rs/blob/159395b365c583a6642ad481edc5708d9f36a24b/crates/napi/src/bindgen_runtime/js_values/arraybuffer.rs#L175), and only defaults to V8 managed array buffers if it is disallowed (like in Electron).
wemeetagain pushed a commit to ChainSafe/zapi that referenced this pull request May 14, 2026
* feat: support create_external_arraybuffer

ported from ChainSafe/lodestar-z#356

External array buffers have their lifetimes managed by V8's garbage
collector, but their backing memory is still managed by the native
implementation.

We need to call `adjustExternalMemory` to let V8 know about the
native allocations; and we need a finalizer to cleanup such
allocations (which we add in this PR, and use in lodestar-z)

More details from that PR:

> This is one of possible likely causes for increased GC pressure on experiments to swap out blst-ts for lodestar-z/bls, as observed on feat2 and feat3 deployments in [this PR](ChainSafe/lodestar#9342).
> 
> With external array buffers, V8 is only aware of the pointer to the backing memory, instead of having to track both the pointer and the backing memory. This means that during marking phase the GC does not have to walk the backing memory to mark it as 'live' - the frequency of the GC firing off is still the same, but each cycle does less work.
> 
> This of course comes with a tradeoff, we need a **finalizer** to let V8 know how much external memory is in native heap  so that the GC tells the native impl to free the useless memory.
> 
> Though, regardless of the effect, we should still probably do this anyway, since [napi-rs does the same](https://github.com/napi-rs/napi-rs/blob/159395b365c583a6642ad481edc5708d9f36a24b/crates/napi/src/bindgen_runtime/js_values/arraybuffer.rs#L175), and only defaults to V8 managed array buffers if it is disallowed (like in Electron).

* add example and test
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