From f291da15a4727b3ce0d97260fd10261dc05d8b49 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 21 Sep 2026 17:47:40 -0700 Subject: [PATCH] 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 Assisted-by: Claude Code --- doc/api/module.md | 24 +++- lib/internal/modules/esm/translators.js | 36 +++-- src/compile_cache.cc | 50 ++++++- src/compile_cache.h | 10 +- src/node_modules.cc | 175 +++++++++++++++++++++++ test/parallel/test-compile-cache-wasm.js | 85 +++++++++++ 6 files changed, 362 insertions(+), 18 deletions(-) create mode 100644 test/parallel/test-compile-cache-wasm.js diff --git a/doc/api/module.md b/doc/api/module.md index 0bd825d9040d..f05726286f09 100644 --- a/doc/api/module.md +++ b/doc/api/module.md @@ -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 diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index 8b90849143d8..583b6d1fe91d 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -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(); diff --git a/src/compile_cache.cc b/src/compile_cache.cc index 6e891619f42b..bab24256e06e 100644 --- a/src/compile_cache.cc +++ b/src/compile_cache.cc @@ -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 code, Local 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(code_utf8.out()), + code_utf8.length(), + filename, + type); +} + +CompileCacheEntry* CompileCacheHandler::GetOrInsert(const uint8_t* code, + size_t code_size, + Local filename, + CachedCodeType type) { DCHECK(!compile_cache_dir_.empty()); Environment* env = Environment::GetCurrent(isolate_->GetCurrentContext()); @@ -275,11 +292,7 @@ CompileCacheEntry* CompileCacheHandler::GetOrInsert(Local 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(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 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 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 mod) { + v8::OwnedBuffer code = mod->GetCompiledModule().Serialize(); + if (code.size == 0) return nullptr; + return new ScriptCompiler::CachedData( + code.buffer.release(), + static_cast(code.size), + ScriptCompiler::CachedData::BufferOwned); +} + template void CompileCacheHandler::MaybeSaveImpl(CompileCacheEntry* entry, Local 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 mod, + bool rejected) { + DCHECK(entry->type == CachedCodeType::kWasm); + MaybeSaveImpl(entry, mod, rejected); +} + void CompileCacheHandler::MaybeSave(CompileCacheEntry* entry, std::string_view transpiled) { if (read_only_) { diff --git a/src/compile_cache.h b/src/compile_cache.h index 0fbce3624a61..527f72f33d42 100644 --- a/src/compile_cache.h +++ b/src/compile_cache.h @@ -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 code, v8::Local filename, CachedCodeType type); + CompileCacheEntry* GetOrInsert(const uint8_t* code, + size_t code_size, + v8::Local filename, + CachedCodeType type); void MaybeSave(CompileCacheEntry* entry, v8::Local func, bool rejected); void MaybeSave(CompileCacheEntry* entry, v8::Local mod, bool rejected); + void MaybeSave(CompileCacheEntry* entry, + v8::Local 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_; } diff --git a/src/node_modules.cc b/src/node_modules.cc index f88fc461f4d6..414e3a780f7f 100644 --- a/src/node_modules.cc +++ b/src/node_modules.cc @@ -1,5 +1,8 @@ #include "node_modules.h" #include +#include +#include +#include #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& 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 DeserializeWasmModule( + Isolate* isolate, + MemorySpan wire, + MemorySpan 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 module; + }; + auto state = std::make_shared(); + + 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> 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>(&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 options, + std::string* constants_module, + WasmModuleObject::CompileOptions* result) { + Isolate* isolate = env->isolate(); + Local context = env->context(); + Local builtins; + if (!options->Get(context, FIXED_ONE_BYTE_STRING(isolate, "builtins")) + .ToLocal(&builtins)) { + return false; + } + if (builtins->IsArray()) { + Local array = builtins.As(); + for (uint32_t i = 0; i < array->Length(); i++) { + Local builtin; + if (!array->Get(context, i).ToLocal(&builtin)) return false; + if (builtin->IsString() && + builtin.As()->StringEquals( + FIXED_ONE_BYTE_STRING(isolate, "js-string"))) { + result->builtins |= + WasmModuleObject::CompileOptions::Builtins::kJsString; + } + } + } + Local 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& args) { + Isolate* isolate = args.GetIsolate(); + CHECK(args[0]->IsArrayBufferView()); + CHECK(args[1]->IsString()); + CHECK(args[2]->IsObject()); + Local context = isolate->GetCurrentContext(); + Environment* env = Environment::GetCurrent(context); + if (!env->use_compile_cache()) { + return; + } + ArrayBufferViewContents source(args[0]); + Local filename = args[1].As(); + auto* cache_entry = env->compile_cache_handler()->GetOrInsert( + source.data(), source.length(), filename, CachedCodeType::kWasm); + if (cache_entry == nullptr) { + return; + } + + LocalVector names(isolate, + {FIXED_ONE_BYTE_STRING(isolate, "external")}); + LocalVector 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(), &constants_module, &options)) { + return; + } + Debug(env, + DebugCategory::COMPILE_CACHE, + "[compile cache] deserializing %s %s...", + cache_entry->type_name(), + cache_entry->source_filename); + Local module; + if (DeserializeWasmModule(isolate, + {source.data(), source.length()}, + {cache_entry->cache->data, + static_cast(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& args) { + Isolate* isolate = args.GetIsolate(); + Local 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( + args[0].As()->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(), + cache_entry->cache != nullptr); +} + void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, Local 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 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 diff --git a/test/parallel/test-compile-cache-wasm.js b/test/parallel/test-compile-cache-wasm.js new file mode 100644 index 000000000000..278198378dd2 --- /dev/null +++ b/test/parallel/test-compile-cache-wasm.js @@ -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; + }, + }); +}