Enable v8 snapshots

There are two layers here. The first is that, on startup, a v8 SnapshotCreator
is created, and a snapshot-specific isolate/context is setup with our browser
environment. This contains most of what was in Env.init and a good chunk of
what was in ExecutionWorld.createContext. From this, we create a v8.StartupData
which is used for the creation of all subsequent contexts. The snapshot sits
at the application level, above the Env - it's re-used for all envs/isolates, so
this gives a nice performance boost for both 1 connection opening multiple pages
or multiple connections opening 1 page.

The second layer is that the Snapshot data can be embedded into the binary, so
that it doesn't have to be created on startup, but rather created at build-time.
This improves the startup time (though, I'm not really sure how to measure that
accurately...).

The first layer is the big win (and just works as-is without any build / usage
changes).

with snapshot
total runs 1000
total duration (ms) 7527
avg run duration (ms) 7
min run duration (ms) 5
max run duration (ms) 41

without snapshot
total runs 1000
total duration (ms) 9350
avg run duration (ms) 9
min run duration (ms) 8
max run duration (ms) 42

To embed a snapshot into the binary, we first need to create the snapshot file:

zig build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin

And then build using the new snapshot_path argument:

zig build -Dsnapshot_path=../../snapshot.bin -Doptimize=ReleaseFast

The paths are weird, I know...since it's embedded, it needs to be inside the
project path, hence we put it in src/snapshot.bin. And since it's embedded
relative to the embedder (src/browser/js/Snapshot.zig) the path has to be
relative to that, hence ../../snapshot.bin. I'm open to suggestions on
improving this.
This commit is contained in:
Karl Seguin
2025-12-18 20:10:38 +08:00
parent aa5e71112e
commit b3a0aaaeea
17 changed files with 693 additions and 409 deletions

View File

@@ -26,14 +26,13 @@ const bridge = @import("bridge.zig");
const Caller = @import("Caller.zig");
const Context = @import("Context.zig");
const Platform = @import("Platform.zig");
const Snapshot = @import("Snapshot.zig");
const Inspector = @import("Inspector.zig");
const ExecutionWorld = @import("ExecutionWorld.zig");
const NamedFunction = Caller.NamedFunction;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const JsApis = bridge.JsApis;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
// The Env maps to a V8 isolate, which represents a isolated sandbox for
// executing JavaScript. The Env is where we'll define our V8 <-> Zig bindings,
@@ -53,28 +52,22 @@ isolate: v8.Isolate,
// just kept around because we need to free it on deinit
isolate_params: *v8.CreateParams,
// Given a type, we can lookup its index in JS_API_LOOKUP and then have
// access to its TunctionTemplate (the thing we need to create an instance
// of it)
// I.e.:
// const index = @field(JS_API_LOOKUP, @typeName(type_name))
// const template = templates[index];
templates: [JsApis.len]v8.FunctionTemplate,
context_id: usize,
const Opts = struct {};
// Dynamic slice to avoid circular dependency on JsApis.len at comptime
templates: []v8.FunctionTemplate,
pub fn init(allocator: Allocator, platform: *const Platform, _: Opts) !*Env {
// var params = v8.initCreateParams();
pub fn init(allocator: Allocator, platform: *const Platform, snapshot: *Snapshot) !Env {
var params = try allocator.create(v8.CreateParams);
errdefer allocator.destroy(params);
v8.c.v8__Isolate__CreateParams__CONSTRUCT(params);
params.snapshot_blob = @ptrCast(&snapshot.startup_data);
params.array_buffer_allocator = v8.createDefaultArrayBufferAllocator();
errdefer v8.destroyArrayBufferAllocator(params.array_buffer_allocator.?);
params.external_references = &snapshot.external_references;
var isolate = v8.Isolate.init(params);
errdefer isolate.deinit();
@@ -88,42 +81,35 @@ pub fn init(allocator: Allocator, platform: *const Platform, _: Opts) !*Env {
isolate.setHostInitializeImportMetaObjectCallback(Context.metaObjectCallback);
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
defer temp_scope.deinit();
// // Allocate templates array dynamically to avoid comptime dependency on JsApis.len
const templates = try allocator.alloc(v8.FunctionTemplate, JsApis.len);
errdefer allocator.free(templates);
const env = try allocator.create(Env);
errdefer allocator.destroy(env);
{
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
defer temp_scope.deinit();
const context = v8.Context.init(isolate, null, null);
env.* = .{
.context_id = 0,
.platform = platform,
.isolate = isolate,
.templates = undefined,
.allocator = allocator,
.isolate_params = params,
};
context.enter();
defer context.exit();
// Populate our templates lookup. generateClass creates the
// v8.FunctionTemplate, which we store in our env.templates.
// The ordering doesn't matter. What matters is that, given a type
// we can get its index via: @field(types.LOOKUP, type_name)
const templates = &env.templates;
inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000);
JsApi.Meta.class_id = i;
templates[i] = v8.Persistent(v8.FunctionTemplate).init(isolate, generateClass(JsApi, isolate)).castToFunctionTemplate();
}
// Above, we've created all our our FunctionTemplates. Now that we
// have them all, we can hook up the prototypes.
inline for (JsApis, 0..) |JsApi, i| {
if (comptime protoIndexLookup(JsApi)) |proto_index| {
templates[i].inherit(templates[proto_index]);
inline for (JsApis, 0..) |JsApi, i| {
JsApi.Meta.class_id = i;
const data = context.getDataFromSnapshotOnce(snapshot.data_start + i);
const function = v8.FunctionTemplate{ .handle = @ptrCast(data) };
templates[i] = v8.Persistent(v8.FunctionTemplate).init(isolate, function).castToFunctionTemplate();
}
}
return env;
return .{
.context_id = 0,
.isolate = isolate,
.platform = platform,
.allocator = allocator,
.templates = templates,
.isolate_params = params,
};
}
pub fn deinit(self: *Env) void {
@@ -131,7 +117,7 @@ pub fn deinit(self: *Env) void {
self.isolate.deinit();
v8.destroyArrayBufferAllocator(self.isolate_params.array_buffer_allocator.?);
self.allocator.destroy(self.isolate_params);
self.allocator.destroy(self);
self.allocator.free(self.templates);
}
pub fn newInspector(self: *Env, arena: Allocator, ctx: anytype) !Inspector {
@@ -149,7 +135,6 @@ pub fn pumpMessageLoop(self: *const Env) bool {
pub fn runIdleTasks(self: *const Env) void {
return self.platform.inner.runIdleTasks(self.isolate, 1);
}
pub fn newExecutionWorld(self: *Env) !ExecutionWorld {
return .{
.env = self,
@@ -207,148 +192,3 @@ fn promiseRejectCallback(v8_msg: v8.C_PromiseRejectMessage) callconv(.c) void {
.note = "This should be updated to call window.unhandledrejection",
});
}
// Give it a Zig struct, get back a v8.FunctionTemplate.
// The FunctionTemplate is a bit like a struct container - it's where
// we'll attach functions/getters/setters and where we'll "inherit" a
// prototype type (if there is any)
fn generateClass(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTemplate {
const template = generateConstructor(JsApi, isolate);
attachClass(JsApi, isolate, template);
return template;
}
// Normally this is called from generateClass. Where generateClass creates
// the constructor (hence, the FunctionTemplate), attachClass adds all
// of its functions, getters, setters, ...
// But it's extracted from generateClass because we also have 1 global
// object (i.e. the Window), which gets attached not only to the Window
// constructor/FunctionTemplate as normal, but also through the default
// FunctionTemplate of the isolate (in createContext)
pub fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionTemplate) void {
const template_proto = template.getPrototypeTemplate();
const declarations = @typeInfo(JsApi).@"struct".decls;
inline for (declarations) |d| {
const name: [:0]const u8 = d.name;
const value = @field(JsApi, name);
const definition = @TypeOf(value);
switch (definition) {
bridge.Accessor => {
const js_name = v8.String.initUtf8(isolate, name).toName();
const getter_callback = v8.FunctionTemplate.initCallback(isolate, value.getter);
if (value.setter == null) {
if (value.static) {
template.setAccessorGetter(js_name, getter_callback);
} else {
template_proto.setAccessorGetter(js_name, getter_callback);
}
} else {
std.debug.assert(value.static == false);
const setter_callback = v8.FunctionTemplate.initCallback(isolate, value.setter);
template_proto.setAccessorGetterAndSetter(js_name, getter_callback, setter_callback);
}
},
bridge.Function => {
const function_template = v8.FunctionTemplate.initCallback(isolate, value.func);
const js_name: v8.Name = v8.String.initUtf8(isolate, name).toName();
if (value.static) {
template.set(js_name, function_template, v8.PropertyAttribute.None);
} else {
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
}
},
bridge.Indexed => {
const configuration = v8.IndexedPropertyHandlerConfiguration{
.getter = value.getter,
};
template_proto.setIndexedProperty(configuration, null);
},
bridge.NamedIndexed => template.getInstanceTemplate().setNamedProperty(.{
.getter = value.getter,
.setter = value.setter,
.deleter = value.deleter,
.flags = v8.PropertyHandlerFlags.OnlyInterceptStrings | v8.PropertyHandlerFlags.NonMasking,
}, null),
bridge.Iterator => {
// Same as a function, but with a specific name
const function_template = v8.FunctionTemplate.initCallback(isolate, value.func);
const js_name = if (value.async)
v8.Symbol.getAsyncIterator(isolate).toName()
else
v8.Symbol.getIterator(isolate).toName();
template_proto.set(js_name, function_template, v8.PropertyAttribute.None);
},
bridge.Property => {
const js_value = switch (value) {
.int => |v| js.simpleZigValueToJs(isolate, v, true, false),
};
const js_name = v8.String.initUtf8(isolate, name).toName();
// apply it both to the type itself
template.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
// and to instances of the type
template_proto.set(js_name, js_value, v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
},
bridge.Constructor => {}, // already handled in generateClasss
else => {},
}
}
if (@hasDecl(JsApi.Meta, "htmldda")) {
const instance_template = template.getInstanceTemplate();
instance_template.markAsUndetectable();
instance_template.setCallAsFunctionHandler(JsApi.Meta.callable.func);
}
}
// Even if a struct doesn't have a `constructor` function, we still
// `generateConstructor`, because this is how we create our
// FunctionTemplate. Such classes exist, but they can't be instantiated
// via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the
// FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: v8.Isolate) v8.FunctionTemplate {
const callback = blk: {
if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func;
}
break :blk struct {
fn wrap(raw_info: ?*const v8.C_FunctionCallbackInfo) callconv(.c) void {
const info = v8.FunctionCallbackInfo.initFromV8(raw_info);
var caller = Caller.init(info);
defer caller.deinit();
const iso = caller.isolate;
log.warn(.js, "Illegal constructor call", .{ .name = @typeName(JsApi) });
const js_exception = iso.throwException(js._createException(iso, "Illegal Constructor"));
info.getReturnValue().set(js_exception);
return;
}
}.wrap;
};
const template = v8.FunctionTemplate.initCallback(isolate, callback);
if (!@hasDecl(JsApi.Meta, "empty_with_no_proto")) {
template.getInstanceTemplate().setInternalFieldCount(1);
}
const class_name = v8.String.initUtf8(isolate, if (@hasDecl(JsApi.Meta, "name")) JsApi.Meta.name else @typeName(JsApi));
template.setClassName(class_name);
return template;
}
pub fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {
@setEvalBranchQuota(2000);
comptime {
const T = JsApi.bridge.type;
if (!@hasField(T, "_proto")) {
return null;
}
const Ptr = std.meta.fieldInfo(T, ._proto).type;
const F = @typeInfo(Ptr).pointer.child;
return bridge.JsApiLookup.getId(F.JsApi);
}
}