Add internal field caching (for window.document and window.console)

This expands the caching capabilities which were first added in
https://github.com/lightpanda-io/browser/pull/1552

Internal field caching requires up-front memory, but is faster. It is currently
enabled for window.document and window.console - two very frequently accessed
values.

Implementations must correctly provide an internal field index, with
consideration for index 0 which may or may not be reserved for the type (it
depends on the type). comptime checks run to make sure this is correct, but it
would probably be nice to at least let them be declared in any order.

This commit also removes the special handling for loading the window. This used
to rely on the window not having any internal fields, but it now has them for
caching so it can't be detected that way. Instead, the window is loaded like any
other object. (But now we have to special case the initial window TAO creation
to make it behave like any other Zig instance).
This commit is contained in:
Karl Seguin
2026-02-16 19:16:39 +08:00
parent 3de9267ea7
commit 01e83b45b5
6 changed files with 98 additions and 40 deletions

View File

@@ -471,13 +471,14 @@ pub const Function = struct {
// We support two ways to cache a value directly into a v8::Object. The
// difference between the two is like the difference between a Map
// and a Struct.
// 1 - Using the object's private state with a v8::Private key. Think of
// this as a HashMap. It takes no memory if the cache isn't used
// but has overhead when used.
// 2 - (TODO) Using the object's internal fields. Think of this as
// 1 - Using the object's internal fields. Think of this as
// adding a field to the struct. It's fast, but the space is reserved
// upfront for _every_ instance, whether we use it or not.
//
// 2 - Using the object's private state with a v8::Private key. Think of
// this as a HashMap. It takes no memory if the cache isn't used
// but has overhead when used.
//
// Consider `window.document`, (1) we have relatively few Window objects,
// (2) They all have a document and (3) The document is accessed _a lot_.
// An internal field makes sense.
@@ -485,9 +486,9 @@ pub const Function = struct {
// Consider `node.childNodes`, (1) we can have 20K+ node objects, (2)
// 95% of nodes will never have their .childNodes access by JavaScript.
// Private map lookup makes sense.
const Caching = union(enum) {
pub const Caching = union(enum) {
internal: u8,
private: []const u8,
// TODO internal_field: u8,
};
};
@@ -567,6 +568,24 @@ pub const Function = struct {
const return_value = info.getReturnValue();
switch (cache) {
.internal => |idx| {
if (v8.v8__Object__GetInternalField(js_this, idx)) |cached| {
// means we can't cache undefined, since we can't tell the
// difference between "it isn't in the cache" and "it's
// in the cache with a valud of undefined"
if (!v8.v8__Value__IsUndefined(cached)) {
return_value.set(cached);
return true;
}
}
// store this so that we can quickly save the result into the cache
cache_state.* = .{
.js_this = js_this,
.v8_context = v8_context,
.mode = .{ .internal = idx },
};
},
.private => |private_symbol| {
const global_handle = &@field(ctx.env.private_symbols, private_symbol).handle;
const private_key: *const v8.Private = v8.v8__Global__Get(global_handle, ctx.isolate.handle).?;
@@ -599,11 +618,14 @@ pub const Function = struct {
js_this: *const v8.Object,
v8_context: *const v8.Context,
mode: union(enum) {
internal: u8,
private: *const v8.Private,
},
pub fn save(self: *const CacheState, comptime cache: Opts.Caching, js_value: js.Value) void {
if (comptime cache == .private) {
if (comptime cache == .internal) {
v8.v8__Object__SetInternalField(self.js_this, self.mode.internal, js_value.handle);
} else {
var out: v8.MaybeBool = undefined;
v8.v8__Object__SetPrivate(self.js_this, self.v8_context, self.mode.private, js_value.handle, &out);
}

View File

@@ -226,14 +226,31 @@ pub fn createContext(self: *Env, page: *Page, enter: bool) !*Context {
// Get the global template that was created once per isolate
const global_template: *const v8.ObjectTemplate = @ptrCast(@alignCast(v8.v8__Eternal__Get(&self.global_template, isolate.handle).?));
v8.v8__ObjectTemplate__SetInternalFieldCount(global_template, comptime Snapshot.countInternalFields(Window.JsApi));
const v8_context = v8.v8__Context__New(isolate.handle, global_template, null).?;
// Create the v8::Context and wrap it in a v8::Global
var context_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, v8_context, &context_global);
// our window wrapped in a v8::Global
// get the global object for the context, this maps to our Window
const global_obj = v8.v8__Context__Global(v8_context).?;
{
// Store our TAO inside the internal field of the global object. This
// maps the v8::Object -> Zig instance. Almost all objects have this, and
// it gets setup automatically as objects are created, but the Window
// object already exists in v8 (it's the global) so we manually create
// the mapping here.
const tao = try context_arena.create(@import("TaggedOpaque.zig"));
tao.* = .{
.value = @ptrCast(page.window),
.prototype_chain = (&Window.JsApi.Meta.prototype_chain).ptr,
.prototype_len = @intCast(Window.JsApi.Meta.prototype_chain.len),
.subtype = .node, // this probably isn't right, but it's what we've been doing all along
};
v8.v8__Object__SetAlignedPointerInInternalField(global_obj, 0, tao);
}
// our window wrapped in a v8::Global
var global_global: v8.Global = undefined;
v8.v8__Global__New(isolate.handle, global_obj, &global_global);

View File

@@ -433,9 +433,12 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
};
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?);
if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, 1);
{
const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) {
const instance_template = v8.v8__FunctionTemplate__InstanceTemplate(template);
v8.v8__ObjectTemplate__SetInternalFieldCount(instance_template, internal_field_count);
}
}
const name_str = if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi);
const class_name = v8.v8__String__NewFromUtf8(isolate, name_str.ptr, v8.kNormal, @intCast(name_str.len));
@@ -443,6 +446,44 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
return template;
}
pub fn countInternalFields(comptime JsApi: type) u8 {
var last_used_id = 0;
var cache_count: u8 = 0;
inline for (@typeInfo(JsApi).@"struct".decls) |d| {
const name: [:0]const u8 = d.name;
const value = @field(JsApi, name);
const definition = @TypeOf(value);
switch (definition) {
inline bridge.Accessor, bridge.Function => {
const cache = value.cache orelse continue;
if (cache != .internal) {
continue;
}
// We assert that they are declared in-order. This isn't necessary
// but I don't want to do anything fancy to look for gaps or
// duplicates.
const internal_id = cache.internal;
if (internal_id != last_used_id + 1) {
@compileError(@typeName(JsApi) ++ "." ++ name ++ " has a non-monotonic cache index");
}
last_used_id = internal_id;
cache_count += 1; // this is just last_used, but it's more explicit this way
},
else => {},
}
}
if (@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
return cache_count;
}
// we need cache_count internal fields, + 1 for the TAO pointer (the v8 -> Zig)
// mapping) itself.
return cache_count + 1;
}
// Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void {
const target = v8.v8__FunctionTemplate__PrototypeTemplate(template);

View File

@@ -95,33 +95,6 @@ pub fn fromJS(comptime R: type, js_obj_handle: *const v8.Object) !R {
}
const internal_field_count = v8.v8__Object__InternalFieldCount(js_obj_handle);
// Special case for Window: the global object doesn't have internal fields
// Window instance is stored in context.page.window instead
if (internal_field_count == 0) {
// Normally, this would be an error. All JsObject that map to a Zig type
// are either `empty_with_no_proto` (handled above) or have an
// interalFieldCount. The only exception to that is the Window...
const isolate = v8.v8__Object__GetIsolate(js_obj_handle).?;
const context = js.Context.fromIsolate(.{ .handle = isolate });
const Window = @import("../webapi/Window.zig");
if (T == Window) {
return context.page.window;
}
// ... Or the window's prototype.
// We could make this all comptime-fancy, but it's easier to hard-code
// the EventTarget
const EventTarget = @import("../webapi/EventTarget.zig");
if (T == EventTarget) {
return context.page.window._proto;
}
// Type not found in Window's prototype chain
return error.InvalidArgument;
}
// if it isn't an empty struct, then the v8.Object should have an
// InternalFieldCount > 0, since our toa pointer should be embedded
// at index 0 of the internal field count.

View File

@@ -160,10 +160,12 @@ pub const Constructor = struct {
pub const Function = struct {
static: bool,
arity: usize,
cache: ?Caller.Function.Opts.Caching = null,
func: *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void,
fn init(comptime T: type, comptime func: anytype, comptime opts: Caller.Function.Opts) Function {
return .{
.cache = opts.cache,
.static = opts.static,
.arity = getArity(@TypeOf(func)),
.func = struct {
@@ -193,11 +195,13 @@ pub const Function = struct {
pub const Accessor = struct {
static: bool = false,
cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
fn init(comptime T: type, comptime getter: anytype, comptime setter: anytype, comptime opts: Caller.Function.Opts) Accessor {
var accessor = Accessor{
.cache = opts.cache,
.static = opts.static,
};

View File

@@ -713,18 +713,19 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } });
pub const top = bridge.accessor(Window.getWindow, null, .{});
pub const self = bridge.accessor(Window.getWindow, null, .{});
pub const window = bridge.accessor(Window.getWindow, null, .{});
pub const parent = bridge.accessor(Window.getWindow, null, .{});
pub const console = bridge.accessor(Window.getConsole, null, .{});
pub const navigator = bridge.accessor(Window.getNavigator, null, .{});
pub const screen = bridge.accessor(Window.getScreen, null, .{});
pub const visualViewport = bridge.accessor(Window.getVisualViewport, null, .{});
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const document = bridge.accessor(Window.getDocument, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{});
pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{});