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; + }, + }); +}