module: cache compiled WebAssembly modules in the compile cache

Extends the module compile cache to WebAssembly modules loaded through
the ES module integration. A new kWasm entry type is keyed on the module
URL with the wire bytes as the hashed source, and stores the serialized
CompiledWasmModule. On load, cached code is deserialized through
v8::WasmModuleCompilation with the same compile options the translator
uses (js-string builtins and imported string constants), falling back
to compilation when V8 rejects it.

As for JavaScript, the cache entry is generated right after compilation.
V8 only serializes optimized tier code, so with the default lazy
baseline compilation there is nothing to cache and no entry is written;
a complete cache requires --no-liftoff --no-wasm-lazy-compilation, which
is documented as the way to use the compile cache for WebAssembly.

Signed-off-by: Guy Bedford <guybedford@gmail.com>
Assisted-by: Claude Code
PR-URL: https://github.com/nodejs/node/pull/66191
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Guy Bedford authored and GitHub committed 2026-10-28 01:46:44 +00:00
1 parent c9b30827e8
commit ec7a28f2e0
6 files changed
+362 -18

No files matched your search

+22 -2
View File
@@ -371,8 +371,9 @@ changes:
The module compile cache can be enabled either using the [`module.enableCompileCache()`][]
method or the [`NODE_COMPILE_CACHE=dir`][] environment variable. After it is enabled,
whenever Node.js compiles a CommonJS, an ECMAScript Module, or a TypeScript module, it will
use on-disk [V8 code cache][] persisted in the specified directory to speed up the compilation.
whenever Node.js compiles a CommonJS, an ECMAScript Module, a TypeScript module, or a
WebAssembly module, it will use on-disk [V8 code cache][] persisted in the specified
directory to speed up the compilation.
This may slow down the first load of a module graph, but subsequent loads of the same module
graph may get a significant speedup if the contents of the modules do not change.
@@ -433,6 +434,25 @@ There are two ways to enable the portable mode:
2. Setting the environment variable: [`NODE_COMPILE_CACHE_PORTABLE=1`][]
### WebAssembly modules in the compile cache
For WebAssembly modules loaded through the ES module integration, the compile cache stores
the machine code that V8 has generated for the module when it is compiled. V8 only
serializes code produced by its optimizing compiler. By default WebAssembly functions are
compiled lazily with a baseline compiler and only optimized once they run hot, so at the time
a module is compiled there is typically no optimized code and no cache entry is written.
To cache WebAssembly modules, compile all functions with the optimizing compiler when the
module is created:
```console
$ NODE_COMPILE_CACHE=/path/to/cache node --no-liftoff --no-wasm-lazy-compilation app.js
```
This makes the first, uncached load of a module slower in exchange for later loads skipping
compilation entirely. Since V8 flags are part of the cache key, the same flags must be
used for all processes sharing the cache.
### Read-only compile cache
A cache that was generated ahead of time, for example at build time to be
+27 -9
View File
@@ -13,6 +13,7 @@ const {
StringPrototypeReplaceAll,
StringPrototypeSlice,
StringPrototypeStartsWith,
Uint8Array,
globalThis,
} = primordials;
@@ -47,6 +48,7 @@ const {
populateCJSExportsFromESM,
} = require('internal/modules/cjs/loader');
const { fileURLToPath, pathToFileURL, URL } = require('internal/url');
const { isAnyArrayBuffer } = require('internal/util/types');
let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
debug = fn;
});
@@ -63,6 +65,10 @@ const { ModuleWrap, kEvaluationPhase } = moduleWrap;
const { getSourceSync } = require('internal/modules/esm/load');
const { parse: cjsParse } = internalBinding('cjs_lexer');
const {
getWasmCompileCacheEntry,
saveWasmCompileCacheEntry,
} = internalBinding('modules');
const translators = new SafeMap();
exports.translators = translators;
@@ -555,6 +561,12 @@ translators.set('json', function jsonStrategy(url, translateContext) {
* >} [[Instance]] slot proxy for WebAssembly Module Record
*/
const wasmInstances = new SafeWeakMap();
// Compile options per https://webassembly.github.io/esm-integration/js-api/index.html#parse-a-webassembly-module.
const wasmCompileOptions = {
__proto__: null,
builtins: ['js-string'],
importedStringConstants: 'wasm:js/string-constants',
};
translators.set('wasm', function(url, translateContext) {
const { source } = translateContext;
// WebAssembly global is not available during snapshot building, so we need to get it lazily.
@@ -566,15 +578,21 @@ translators.set('wasm', function(url, translateContext) {
debug(`Translating WASMModule ${url}`, translateContext);
let compiled;
try {
compiled = new WebAssembly.Module(source, {
builtins: ['js-string'],
importedStringConstants: 'wasm:js/string-constants',
});
} catch (err) {
err.message = errPath(url) + ': ' + err.message;
throw err;
// When the compile cache is enabled, the cache entry holds the module
// deserialized from cached optimized code if V8 accepted it.
const cacheEntry = getWasmCompileCacheEntry(
isAnyArrayBuffer(source) ? new Uint8Array(source) : source, url, wasmCompileOptions);
let compiled = cacheEntry?.module;
if (compiled === undefined) {
try {
compiled = new WebAssembly.Module(source, wasmCompileOptions);
} catch (err) {
err.message = errPath(url) + ': ' + err.message;
throw err;
}
if (cacheEntry !== undefined) {
saveWasmCompileCacheEntry(cacheEntry.external, compiled);
}
}
const importsList = new SafeSet();
+44 -6
View File
@@ -23,6 +23,7 @@ using v8::Local;
using v8::Module;
using v8::ScriptCompiler;
using v8::String;
using v8::WasmModuleObject;
namespace {
std::string Uint32ToHex(uint32_t crc) {
@@ -103,6 +104,8 @@ const char* CompileCacheEntry::type_name() const {
return "ESM";
case CachedCodeType::kStrippedTypeScript:
return "StrippedTypeScript";
case CachedCodeType::kWasm:
return "Wasm";
default:
UNREACHABLE();
}
@@ -249,6 +252,20 @@ static std::string GetRelativePath(std::string_view path,
CompileCacheEntry* CompileCacheHandler::GetOrInsert(Local<String> code,
Local<String> filename,
CachedCodeType type) {
// TODO(joyeecheung): don't encode this again into UTF8. If we read the
// UTF8 content on disk as raw buffer (from the JS layer, while watching out
// for monkey patching), we can just hash it directly.
Utf8Value code_utf8(isolate_, code);
return GetOrInsert(reinterpret_cast<const uint8_t*>(code_utf8.out()),
code_utf8.length(),
filename,
type);
}
CompileCacheEntry* CompileCacheHandler::GetOrInsert(const uint8_t* code,
size_t code_size,
Local<String> filename,
CachedCodeType type) {
DCHECK(!compile_cache_dir_.empty());
Environment* env = Environment::GetCurrent(isolate_->GetCurrentContext());
@@ -275,11 +292,7 @@ CompileCacheEntry* CompileCacheHandler::GetOrInsert(Local<String> code,
}
uint32_t key = GetCacheKey(file_path, type);
// TODO(joyeecheung): don't encode this again into UTF8. If we read the
// UTF8 content on disk as raw buffer (from the JS layer, while watching out
// for monkey patching), we can just hash it directly.
Utf8Value code_utf8(isolate_, code);
uint32_t code_hash = GetHash(code_utf8.out(), code_utf8.length());
uint32_t code_hash = GetHash(reinterpret_cast<const char*>(code), code_size);
auto loaded = compiler_cache_store_.find(key);
// TODO(joyeecheung): let V8's in-isolate compilation cache take precedence.
@@ -295,7 +308,7 @@ CompileCacheEntry* CompileCacheHandler::GetOrInsert(Local<String> code,
auto* result = emplaced.first->second.get();
result->code_hash = code_hash;
result->code_size = code_utf8.length();
result->code_size = code_size;
result->cache_key = key;
result->cache_filename =
compile_cache_dir_ + kPathSeparator + Uint32ToHex(key);
@@ -318,6 +331,18 @@ ScriptCompiler::CachedData* SerializeCodeCache(Local<Module> mod) {
return ScriptCompiler::CreateCodeCache(mod->GetUnboundModuleScript());
}
// V8 only serializes code compiled by the optimizing tier, and produces
// nothing if there is none, e.g. for a module that has only been compiled
// with the baseline tier so far.
ScriptCompiler::CachedData* SerializeCodeCache(Local<WasmModuleObject> mod) {
v8::OwnedBuffer code = mod->GetCompiledModule().Serialize();
if (code.size == 0) return nullptr;
return new ScriptCompiler::CachedData(
code.buffer.release(),
static_cast<int>(code.size),
ScriptCompiler::CachedData::BufferOwned);
}
template <typename T>
void CompileCacheHandler::MaybeSaveImpl(CompileCacheEntry* entry,
Local<T> func_or_mod,
@@ -341,6 +366,12 @@ void CompileCacheHandler::MaybeSaveImpl(CompileCacheEntry* entry,
entry->cache == nullptr ? "initializing" : "refreshing");
ScriptCompiler::CachedData* data = SerializeCodeCache(func_or_mod);
if (data == nullptr) {
Debug("[compile cache] nothing to serialize for %s %s\n",
entry->type_name(),
entry->source_filename);
return;
}
DCHECK_EQ(data->buffer_policy, ScriptCompiler::CachedData::BufferOwned);
entry->refreshed = true;
entry->cache.reset(data);
@@ -359,6 +390,13 @@ void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry,
MaybeSaveImpl(entry, func, rejected);
}
void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry,
Local<WasmModuleObject> mod,
bool rejected) {
DCHECK(entry->type == CachedCodeType::kWasm);
MaybeSaveImpl(entry, mod, rejected);
}
void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry,
std::string_view transpiled) {
if (read_only_) {
+9 -1
View File
@@ -16,7 +16,8 @@ class Environment;
#define CACHED_CODE_TYPES(V) \
V(kCommonJS, 0) \
V(kESM, 1) \
V(kStrippedTypeScript, 2)
V(kStrippedTypeScript, 2) \
V(kWasm, 3)
enum class CachedCodeType : uint8_t {
#define V(type, value) type = value,
@@ -89,12 +90,19 @@ class CompileCacheHandler {
CompileCacheEntry* GetOrInsert(v8::Local<v8::String> code,
v8::Local<v8::String> filename,
CachedCodeType type);
CompileCacheEntry* GetOrInsert(const uint8_t* code,
size_t code_size,
v8::Local<v8::String> filename,
CachedCodeType type);
void MaybeSave(CompileCacheEntry* entry,
v8::Local<v8::Function> func,
bool rejected);
void MaybeSave(CompileCacheEntry* entry,
v8::Local<v8::Module> mod,
bool rejected);
void MaybeSave(CompileCacheEntry* entry,
v8::Local<v8::WasmModuleObject> mod,
bool rejected);
void MaybeSave(CompileCacheEntry* entry, std::string_view transpiled);
std::string_view cache_dir() { return compile_cache_dir_; }
bool read_only() const { return read_only_; }
+175
View File
@@ -1,5 +1,8 @@
#include "node_modules.h"
#include <cstdio>
#include <memory>
#include <string>
#include <variant>
#include "base_object-inl.h"
#include "compile_cache.h"
#include "node_errors.h"
@@ -25,11 +28,14 @@ using v8::Boolean;
using v8::Context;
using v8::External;
using v8::FunctionCallbackInfo;
using v8::Global;
using v8::HandleScope;
using v8::Integer;
using v8::Isolate;
using v8::Local;
using v8::LocalVector;
using v8::MaybeLocal;
using v8::MemorySpan;
using v8::Name;
using v8::NewStringType;
using v8::Null;
@@ -39,6 +45,9 @@ using v8::Primitive;
using v8::String;
using v8::Undefined;
using v8::Value;
using v8::WasmModuleCompilation;
using v8::WasmModuleObject;
using v8::WasmStreaming;
void BindingData::MemoryInfo(MemoryTracker* tracker) const {
// Do nothing
@@ -774,6 +783,166 @@ void SaveCompileCacheEntry(const FunctionCallbackInfo<Value>& args) {
env->compile_cache_handler()->MaybeSave(cache_entry, utf8.ToStringView());
}
// Deserializes cached compiled code for the given wire bytes. Returns an empty
// MaybeLocal if V8 rejects the cached code, in which case the module must be
// compiled from the wire bytes as usual.
MaybeLocal<WasmModuleObject> DeserializeWasmModule(
Isolate* isolate,
MemorySpan<const uint8_t> wire,
MemorySpan<const uint8_t> code,
const WasmModuleObject::CompileOptions& options) {
// The resolution callback runs inside a HandleScope owned by V8, so the
// result must be persisted in a Global to outlive it.
struct State {
bool in_finish = true;
bool accepted = false;
Global<WasmModuleObject> module;
};
auto state = std::make_shared<State>();
WasmModuleCompilation compilation(options);
compilation.SetHasCompiledModuleBytes();
compilation.OnBytesReceived(wire.data(), wire.size());
compilation.Finish(
isolate,
[&](WasmStreaming::ModuleCachingInterface& cache) {
state->accepted = cache.SetCachedCompiledModuleBytes(code);
},
[state,
isolate](std::variant<Local<WasmModuleObject>, Local<Value>> result) {
// When the cached code is accepted, V8 resolves synchronously within
// Finish(). Otherwise V8 falls back to compiling the wire bytes
// asynchronously; that result arrives later and is unused.
if (!state->in_finish) return;
if (auto* module = std::get_if<Local<WasmModuleObject>>(&result)) {
state->module.Reset(isolate, *module);
}
});
state->in_finish = false;
if (!state->accepted || state->module.IsEmpty()) return {};
return state->module.Get(isolate);
}
// Reads `{ builtins, importedStringConstants }` as accepted by the
// WebAssembly.Module constructor. `constants_module` provides storage for the
// string referenced by the returned options.
bool ParseWasmCompileOptions(Environment* env,
Local<Object> options,
std::string* constants_module,
WasmModuleObject::CompileOptions* result) {
Isolate* isolate = env->isolate();
Local<Context> context = env->context();
Local<Value> builtins;
if (!options->Get(context, FIXED_ONE_BYTE_STRING(isolate, "builtins"))
.ToLocal(&builtins)) {
return false;
}
if (builtins->IsArray()) {
Local<Array> array = builtins.As<Array>();
for (uint32_t i = 0; i < array->Length(); i++) {
Local<Value> builtin;
if (!array->Get(context, i).ToLocal(&builtin)) return false;
if (builtin->IsString() &&
builtin.As<String>()->StringEquals(
FIXED_ONE_BYTE_STRING(isolate, "js-string"))) {
result->builtins |=
WasmModuleObject::CompileOptions::Builtins::kJsString;
}
}
}
Local<Value> constants;
if (!options
->Get(context,
FIXED_ONE_BYTE_STRING(isolate, "importedStringConstants"))
.ToLocal(&constants)) {
return false;
}
if (constants->IsString()) {
*constants_module = Utf8Value(isolate, constants).ToString();
result->imported_string_constants_module = constants_module->c_str();
}
return true;
}
// Returns undefined when the compile cache is disabled or not applicable,
// otherwise an object with an `external` handle to the cache entry, and, when
// the cached compiled code was accepted by V8, the deserialized `module`.
void GetWasmCompileCacheEntry(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
CHECK(args[0]->IsArrayBufferView());
CHECK(args[1]->IsString());
CHECK(args[2]->IsObject());
Local<Context> context = isolate->GetCurrentContext();
Environment* env = Environment::GetCurrent(context);
if (!env->use_compile_cache()) {
return;
}
ArrayBufferViewContents<uint8_t> source(args[0]);
Local<String> filename = args[1].As<String>();
auto* cache_entry = env->compile_cache_handler()->GetOrInsert(
source.data(), source.length(), filename, CachedCodeType::kWasm);
if (cache_entry == nullptr) {
return;
}
LocalVector<Name> names(isolate,
{FIXED_ONE_BYTE_STRING(isolate, "external")});
LocalVector<Value> values(
isolate,
{External::New(
isolate, cache_entry, v8::kExternalPointerTypeTagDefault)});
if (cache_entry->cache != nullptr) {
std::string constants_module;
WasmModuleObject::CompileOptions options;
if (!ParseWasmCompileOptions(
env, args[2].As<Object>(), &constants_module, &options)) {
return;
}
Debug(env,
DebugCategory::COMPILE_CACHE,
"[compile cache] deserializing %s %s...",
cache_entry->type_name(),
cache_entry->source_filename);
Local<WasmModuleObject> module;
if (DeserializeWasmModule(isolate,
{source.data(), source.length()},
{cache_entry->cache->data,
static_cast<size_t>(cache_entry->cache->length)},
options)
.ToLocal(&module)) {
Debug(env, DebugCategory::COMPILE_CACHE, "success\n");
names.push_back(FIXED_ONE_BYTE_STRING(isolate, "module"));
values.push_back(module);
} else {
Debug(env, DebugCategory::COMPILE_CACHE, "rejected\n");
}
} else {
Debug(env,
DebugCategory::COMPILE_CACHE,
"[compile cache] no cache for %s %s\n",
cache_entry->type_name(),
cache_entry->source_filename);
}
args.GetReturnValue().Set(Object::New(
isolate, v8::Null(isolate), names.data(), values.data(), names.size()));
}
void SaveWasmCompileCacheEntry(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
Local<Context> context = isolate->GetCurrentContext();
Environment* env = Environment::GetCurrent(context);
DCHECK(env->use_compile_cache());
CHECK(args[0]->IsExternal());
CHECK(args[1]->IsWasmModuleObject());
auto* cache_entry = static_cast<CompileCacheEntry*>(
args[0].As<External>()->Value(v8::kExternalPointerTypeTagDefault));
// Only called when the module was compiled from the wire bytes, so any
// existing cache was rejected.
env->compile_cache_handler()->MaybeSave(cache_entry,
args[1].As<WasmModuleObject>(),
cache_entry->cache != nullptr);
}
void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
Local<ObjectTemplate> target) {
Isolate* isolate = isolate_data->isolate();
@@ -796,6 +965,10 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
SetMethod(isolate, target, "flushCompileCache", FlushCompileCache);
SetMethod(isolate, target, "getCompileCacheEntry", GetCompileCacheEntry);
SetMethod(isolate, target, "saveCompileCacheEntry", SaveCompileCacheEntry);
SetMethod(
isolate, target, "getWasmCompileCacheEntry", GetWasmCompileCacheEntry);
SetMethod(
isolate, target, "saveWasmCompileCacheEntry", SaveWasmCompileCacheEntry);
}
void BindingData::CreatePerContextProperties(Local<Object> target,
@@ -869,6 +1042,8 @@ void BindingData::RegisterExternalReferences(
registry->Register(FlushCompileCache);
registry->Register(GetCompileCacheEntry);
registry->Register(SaveCompileCacheEntry);
registry->Register(GetWasmCompileCacheEntry);
registry->Register(SaveWasmCompileCacheEntry);
}
} // namespace modules
+85
View File
@@ -0,0 +1,85 @@
'use strict';
// This tests NODE_COMPILE_CACHE works for WebAssembly modules imported as ESM.
require('../common');
const { spawnSyncAndAssert } = require('../common/child_process');
const assert = require('assert');
const tmpdir = require('../common/tmpdir');
const fixtures = require('../common/fixtures');
const script = fixtures.path('es-modules', 'test-wasm-js-string-builtins.mjs');
// V8 only serializes optimized code, so a complete cache requires eager
// optimizing compilation.
{
tmpdir.refresh();
const dir = tmpdir.resolve('.compile_cache_dir');
const flags = ['--no-liftoff', '--no-wasm-lazy-compilation', '--no-warnings'];
spawnSyncAndAssert(
process.execPath,
[...flags, script],
{
env: {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir,
},
cwd: tmpdir.path,
},
{
stderr(output) {
assert.match(output, /no cache for Wasm .*js-string-builtins\.wasm/);
assert.match(output, /V8 code cache for Wasm .*js-string-builtins\.wasm was not initialized, initializing the in-memory entry/);
assert.match(output, /writing cache for Wasm .*js-string-builtins\.wasm.*success/);
return true;
},
});
spawnSyncAndAssert(
process.execPath,
[...flags, script],
{
env: {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir,
},
cwd: tmpdir.path,
},
{
stderr(output) {
assert.match(output, /reading cache from .* for Wasm .*js-string-builtins\.wasm.*success/);
assert.match(output, /deserializing Wasm .*js-string-builtins\.wasm.*success/);
assert.match(output, /skip persisting Wasm .*js-string-builtins\.wasm because cache was the same/);
return true;
},
});
}
// With the default lazy baseline compilation there is no optimized code to
// cache at compile time, and no cache entry is written.
{
tmpdir.refresh();
const dir = tmpdir.resolve('.compile_cache_dir');
spawnSyncAndAssert(
process.execPath,
['--no-warnings', script],
{
env: {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir,
},
cwd: tmpdir.path,
},
{
stderr(output) {
assert.match(output, /nothing to serialize for Wasm .*js-string-builtins\.wasm/);
assert.match(output, /skip persisting Wasm .*js-string-builtins\.wasm because the cache was not initialized/);
return true;
},
});
}