diff --git a/src/node.cc b/src/node.cc index 00282634419d..2b5c4081652b 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1286,6 +1286,10 @@ InitializeOncePerProcessInternal(const std::vector& args, cppgc::InitializeProcess(allocator); } + if (flags & ProcessInitializationFlags::kNoHarvestBuiltinCodeCache) { + builtins::BuiltinLoader::SetHarvestCodeCache(false); + } + if (!(flags & ProcessInitializationFlags::kNoInitializeV8)) { V8::Initialize(); diff --git a/src/node.h b/src/node.h index bf6537e3bfe6..a877e58f4834 100644 --- a/src/node.h +++ b/src/node.h @@ -233,6 +233,11 @@ enum Flags : uint32_t { kNoInitializeCppgc = 1 << 13, // Initialize the process for predictable snapshot generation. kGeneratePredictableSnapshot = 1 << 14, + // Do not serialize a code cache for builtins that had to be compiled without + // one. By default such caches are kept so that worker threads created later + // start faster; an embedder that supplies its own cache (SetBuiltinCodeCache) + // or never creates workers only pays for the serialization. + kNoHarvestBuiltinCodeCache = 1 << 15, // Emulate the behavior of InitializeNodeWithArgs() when passing // a flags argument to the InitializeOncePerProcess() replacement @@ -678,6 +683,36 @@ struct InspectorParentHandle { virtual ~InspectorParentHandle() = default; }; +// A V8 code cache for one of Node.js's built-in JavaScript modules. +struct BuiltinCodeCacheEntry { + std::string id; // e.g. "internal/bootstrap/node" + const uint8_t* data; // must stay valid for the rest of the process + size_t length; +}; +struct OwnedBuiltinCodeCacheEntry { + std::string id; + std::vector data; +}; + +// Contexts and Environments created from Node.js's built-in snapshot get the +// builtins' code cache from that snapshot. An embedder that bootstraps them +// from scratch (its own isolate/context, no EmbedderSnapshotData) can supply a +// cache built ahead of time with GenerateBuiltinCodeCache() against the same +// kind of isolate (same V8 version, flags and read-only snapshot): every +// Environment created afterwards, and the loader for the per-context scripts +// run by NewContext(), start with these entries. Entries a snapshot provides +// still apply. Call before creating contexts/Environments; may be called +// again to replace the set for later ones. +NODE_EXTERN void SetBuiltinCodeCache( + const std::vector& entries); + +// Compiles every built-in module in `context` (which must have been created +// with node::NewContext() in the kind of isolate the cache is for) and returns +// their code caches, e.g. for a build step that embeds them and passes them to +// SetBuiltinCodeCache() at runtime. Returns an empty vector on failure. +NODE_EXTERN std::vector GenerateBuiltinCodeCache( + v8::Local context); + // TODO(addaleax): Maybe move per-Environment options parsing here. // Returns nullptr when the Environment cannot be created e.g. there are // pending JavaScript exceptions. diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 43a9a388c2ba..fea41d62586f 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -12,7 +12,6 @@ #include "v8-value.h" namespace node { -namespace builtins { using loader::HostDefinedOptions; using v8::Boolean; @@ -44,9 +43,41 @@ using v8::TryCatch; using v8::Undefined; using v8::Value; +namespace builtins { + +namespace { +struct ProcessCodeCache { + Mutex mutex; + std::vector entries; + bool harvest = true; +}; +ProcessCodeCache& GetProcessCodeCache() { + static ProcessCodeCache process_code_cache; + return process_code_cache; +} +} // namespace + +void BuiltinLoader::SetProcessCodeCache(std::vector entries) { + ProcessCodeCache& pcc = GetProcessCodeCache(); + Mutex::ScopedLock lock(pcc.mutex); + pcc.entries = std::move(entries); +} + +void BuiltinLoader::SetHarvestCodeCache(bool on) { + ProcessCodeCache& pcc = GetProcessCodeCache(); + Mutex::ScopedLock lock(pcc.mutex); + pcc.harvest = on; +} + BuiltinLoader::BuiltinLoader() : config_(GetConfig()), code_cache_(std::make_shared()) { LoadJavaScriptSource(); + { + ProcessCodeCache& pcc = GetProcessCodeCache(); + Mutex::ScopedLock lock(pcc.mutex); + harvest_code_cache_ = pcc.harvest; + if (!pcc.entries.empty()) RefreshCodeCache(pcc.entries); + } #ifdef NODE_SHARED_BUILTIN_UNDICI_UNDICI_PATH AddExternalizedBuiltin("internal/deps/undici/undici", STRINGIFY(NODE_SHARED_BUILTIN_UNDICI_UNDICI_PATH)); @@ -422,6 +453,7 @@ MaybeLocal BuiltinLoader::LookupAndCompile( } if (result == Result::kWithoutCache && optional_realm != nullptr && + harvest_code_cache_ && !optional_realm->env()->isolate_data()->is_building_snapshot()) { // We failed to accept this cache, maybe because it was rejected, maybe // because it wasn't present. Either way, we'll attempt to replace this @@ -593,12 +625,13 @@ bool BuiltinLoader::CompileAllBuiltinsAndCopyCodeCache( void BuiltinLoader::RefreshCodeCache(const std::vector& in) { RwLock::ScopedLock lock(code_cache_->mutex); - code_cache_->map.reserve(in.size()); - DCHECK(code_cache_->map.empty()); + // May be called more than once, e.g. first with the code cache carried by + // the snapshot and then by an embedder with caches it built for additional + // (or the same) builtin ids against this isolate: merge, and let the entry + // supplied last win for an id present in both. + code_cache_->map.reserve(code_cache_->map.size() + in.size()); for (auto const& [id, data] : in) { - auto result = code_cache_->map.emplace(id, data); - USE(result.second); - DCHECK(result.second); + code_cache_->map.insert_or_assign(id, data); } code_cache_->has_code_cache = true; } @@ -918,6 +951,38 @@ void BuiltinLoader::RegisterExternalReferences( } } // namespace builtins + +void SetBuiltinCodeCache(const std::vector& entries) { + std::vector infos; + infos.reserve(entries.size()); + for (const BuiltinCodeCacheEntry& e : entries) { + auto cached_data = std::make_shared( + e.data, + static_cast(e.length), + ScriptCompiler::CachedData::BufferNotOwned); + infos.push_back({e.id, builtins::BuiltinCodeCacheData(cached_data)}); + } + builtins::BuiltinLoader::SetProcessCodeCache(std::move(infos)); +} + +std::vector GenerateBuiltinCodeCache( + Local context) { + std::vector out; + builtins::BuiltinLoader loader; + loader.SetEagerCompile(); + std::vector infos; + if (!loader.CompileAllBuiltinsAndCopyCodeCache(context, {}, &infos)) { + return out; + } + out.reserve(infos.size()); + for (const builtins::CodeCacheInfo& info : infos) { + out.push_back({info.id, + std::vector(info.data.data, + info.data.data + info.data.length)}); + } + return out; +} + } // namespace node NODE_BINDING_PER_ISOLATE_INIT( diff --git a/src/node_builtins.h b/src/node_builtins.h index b51b85ff6f23..8c70505769a6 100644 --- a/src/node_builtins.h +++ b/src/node_builtins.h @@ -125,8 +125,21 @@ class NODE_EXTERN_PRIVATE BuiltinLoader { v8::Local context, const std::vector& lazy_builtins, std::vector* out); + // Adds the given code cache entries, replacing existing entries with the + // same id. Can be called more than once (e.g. with the snapshot's code cache + // and then with caches an embedder built for further builtin ids). void RefreshCodeCache(const std::vector& in); + // Process-wide entries every BuiltinLoader created afterwards starts with + // (each Environment's and the per-context script loader): lets embedders + // whose contexts are not deserialized from a snapshot still compile the + // builtins with a cache. See node::SetBuiltinCodeCache(). + static void SetProcessCodeCache(std::vector entries); + // Whether builtins compiled without a cache serialize one for later + // consumers (worker threads copy it). See + // ProcessInitializationFlags::kNoHarvestBuiltinCodeCache. + static void SetHarvestCodeCache(bool on); + void CopySourceAndCodeCacheReferenceFrom(const BuiltinLoader* other); [[nodiscard]] std::ranges::keys_view< @@ -217,6 +230,7 @@ class NODE_EXTERN_PRIVATE BuiltinLoader { // avoid bloating the binary size). At runtime any additional compilation is // done lazily. bool should_eager_compile_ = false; + bool harvest_code_cache_ = true; std::unordered_set to_eager_compile_; struct BuiltinCodeCache { diff --git a/test/cctest/test_per_process.cc b/test/cctest/test_per_process.cc index 7a6f53d56222..e1473cbc0a12 100644 --- a/test/cctest/test_per_process.cc +++ b/test/cctest/test_per_process.cc @@ -4,18 +4,45 @@ #include "gtest/gtest.h" #include "node_test_fixture.h" +#include +#include #include +#include +using node::builtins::BuiltinCodeCacheData; using node::builtins::BuiltinLoader; using node::builtins::BuiltinSourceMap; +using node::builtins::CodeCacheInfo; class PerProcessTest : public ::testing::Test { protected: static const BuiltinSourceMap get_sources_for_test() { return *BuiltinLoader().source_.read(); } + + // id -> first byte of the cached data, after feeding `batches` in order. + static std::vector> RefreshCodeCacheWith( + const std::vector>& batches) { + BuiltinLoader loader; + for (const auto& batch : batches) loader.RefreshCodeCache(batch); + std::vector> out; + node::RwLock::ScopedReadLock lock(loader.code_cache_->mutex); + EXPECT_TRUE(loader.code_cache_->has_code_cache); + for (const auto& [id, data] : loader.code_cache_->map) { + out.emplace_back(id, data.data[0]); + } + std::sort(out.begin(), out.end()); + return out; + } }; +CodeCacheInfo MakeCodeCacheInfo(const std::string& id, uint8_t marker) { + auto* bytes = new uint8_t[4]{marker, marker, marker, marker}; + auto cached_data = std::make_shared( + bytes, 4, v8::ScriptCompiler::CachedData::BufferOwned); + return CodeCacheInfo{id, BuiltinCodeCacheData(std::move(cached_data))}; +} + namespace { TEST_F(PerProcessTest, EmbeddedSources) { @@ -29,4 +56,36 @@ TEST_F(PerProcessTest, EmbeddedSources) { })) << "BuiltinLoader::source_ should have some 16bit items"; } +// RefreshCodeCache() merges: it can be fed the snapshot's code cache and then +// an embedder's, and the entry supplied last wins for a shared id. +TEST_F(PerProcessTest, RefreshCodeCacheMerges) { + const auto merged = PerProcessTest::RefreshCodeCacheWith({ + {MakeCodeCacheInfo("internal/a", 1), MakeCodeCacheInfo("internal/b", 1)}, + {MakeCodeCacheInfo("internal/b", 2), MakeCodeCacheInfo("embedder/c", 2)}, + }); + const std::vector> expected = { + {"embedder/c", 2}, {"internal/a", 1}, {"internal/b", 2}}; + EXPECT_EQ(merged, expected); + + // A single call still behaves as before. + const auto single = PerProcessTest::RefreshCodeCacheWith( + {{MakeCodeCacheInfo("internal/a", 7)}}); + ASSERT_EQ(single.size(), 1u); + EXPECT_EQ(single[0].second, 7); +} + +// SetProcessCodeCache() seeds every BuiltinLoader created afterwards, and a +// later RefreshCodeCache() (e.g. from a snapshot) merges on top of the seed. +TEST_F(PerProcessTest, ProcessCodeCacheSeedsNewLoaders) { + BuiltinLoader::SetProcessCodeCache( + {MakeCodeCacheInfo("internal/a", 3), MakeCodeCacheInfo("embedder/x", 3)}); + const auto seeded = PerProcessTest::RefreshCodeCacheWith( + {{MakeCodeCacheInfo("internal/a", 4)}}); + const std::vector> expected = { + {"embedder/x", 3}, {"internal/a", 4}}; + EXPECT_EQ(seeded, expected); + BuiltinLoader::SetProcessCodeCache({}); + EXPECT_TRUE(PerProcessTest::RefreshCodeCacheWith({{}}).empty()); +} + } // end namespace diff --git a/test/embedding/embedtest.cc b/test/embedding/embedtest.cc index 982eed74f954..031bd32e055c 100644 --- a/test/embedding/embedtest.cc +++ b/test/embedding/embedtest.cc @@ -2,6 +2,7 @@ #undef NDEBUG #endif #include +#include #include "cppgc/platform.h" #include "executable_wrapper.h" #include "node.h" @@ -24,6 +25,57 @@ using v8::MaybeLocal; using v8::V8; using v8::Value; +// Builtin code cache file used by --builtin-code-cache[-create]: +// u32 count, then per entry: u32 id length, id bytes, u32 data length, data. +static std::vector code_cache_file; // backs the entries for the process + +static void LoadBuiltinCodeCache(const std::string& path) { + FILE* fp = fopen(path.c_str(), "rb"); + assert(fp != nullptr); + fseek(fp, 0, SEEK_END); + code_cache_file.resize(ftell(fp)); + fseek(fp, 0, SEEK_SET); + size_t r = fread(code_cache_file.data(), 1, code_cache_file.size(), fp); + assert(r == code_cache_file.size()); + fclose(fp); + const char* p = code_cache_file.data(); + auto u32 = [&p]() { + uint32_t v; + memcpy(&v, p, 4); + p += 4; + return v; + }; + std::vector entries(u32()); + for (node::BuiltinCodeCacheEntry& e : entries) { + uint32_t idlen = u32(); + e.id.assign(p, idlen); + p += idlen; + e.length = u32(); + e.data = reinterpret_cast(p); + p += e.length; + } + node::SetBuiltinCodeCache(entries); +} + +static int WriteBuiltinCodeCache(v8::Local context, + const std::string& path) { + std::vector entries = + node::GenerateBuiltinCodeCache(context); + if (entries.empty()) return 1; + FILE* fp = fopen(path.c_str(), "wb"); + assert(fp != nullptr); + auto u32 = [fp](uint32_t v) { fwrite(&v, 4, 1, fp); }; + u32(static_cast(entries.size())); + for (const node::OwnedBuiltinCodeCacheEntry& e : entries) { + u32(static_cast(e.id.size())); + fwrite(e.id.data(), 1, e.id.size(), fp); + u32(static_cast(e.data.size())); + fwrite(e.data.data(), 1, e.data.size(), fp); + } + fclose(fp); + return 0; +} + static int RunNodeInstance(MultiIsolatePlatform* platform, const std::vector& args, const std::vector& exec_args); @@ -33,19 +85,24 @@ NODE_MAIN(int argc, node::argv_type raw_argv[]) { node::FixupMain(argc, raw_argv, &argv); std::vector args(argv, argv + argc); + uint32_t flags = + node::ProcessInitializationFlags::kNoInitializeV8 | + node::ProcessInitializationFlags::kNoInitializeNodeV8Platform | + // This is used to test NODE_REPL_EXTERNAL_MODULE is disabled with + // kDisableNodeOptionsEnv. If other tests need NODE_OPTIONS + // support in the future, split this configuration out as a + // command line option. + node::ProcessInitializationFlags::kDisableNodeOptionsEnv | + node::ProcessInitializationFlags::kNoInitializeCppgc; + auto it = + std::find(args.begin(), args.end(), "--no-harvest-builtin-code-cache"); + if (it != args.end()) { + args.erase(it); + flags |= node::ProcessInitializationFlags::kNoHarvestBuiltinCodeCache; + } std::shared_ptr result = node::InitializeOncePerProcess( - args, - { - node::ProcessInitializationFlags::kNoInitializeV8, - node::ProcessInitializationFlags::kNoInitializeNodeV8Platform, - // This is used to test NODE_REPL_EXTERNAL_MODULE is disabled with - // kDisableNodeOptionsEnv. If other tests need NODE_OPTIONS - // support in the future, split this configuration out as a - // command line option. - node::ProcessInitializationFlags::kDisableNodeOptionsEnv, - node::ProcessInitializationFlags::kNoInitializeCppgc, - }); + args, static_cast(flags)); for (const std::string& error : result->errors()) fprintf(stderr, "%s: %s\n", args[0].c_str(), error.c_str()); @@ -95,6 +152,7 @@ int RunNodeInstance(MultiIsolatePlatform* platform, bool snapshot_as_file = false; std::optional snapshot_config; std::string snapshot_blob_path; + std::string code_cache_out_path; for (size_t i = 0; i < args.size(); ++i) { const std::string& arg = args[i]; if (arg == "--embedder-snapshot-create") { @@ -112,6 +170,14 @@ int RunNodeInstance(MultiIsolatePlatform* platform, assert(i + 1 < args.size()); snapshot_blob_path = args[i + 1]; i++; + } else if (arg == "--builtin-code-cache-create") { + assert(i + 1 < args.size()); + code_cache_out_path = args[i + 1]; + i++; + } else if (arg == "--builtin-code-cache") { + assert(i + 1 < args.size()); + LoadBuiltinCodeCache(args[i + 1]); + i++; } else { filtered_args.push_back(arg); } @@ -183,6 +249,10 @@ int RunNodeInstance(MultiIsolatePlatform* platform, HandleScope handle_scope(isolate); Context::Scope context_scope(setup->context()); + if (!code_cache_out_path.empty()) { + return WriteBuiltinCodeCache(setup->context(), code_cache_out_path); + } + MaybeLocal loadenv_ret; if (snapshot) { // Deserializing snapshot loadenv_ret = node::LoadEnvironment(env, node::StartExecutionCallback{}); diff --git a/test/embedding/test-embedding-builtin-code-cache.js b/test/embedding/test-embedding-builtin-code-cache.js new file mode 100644 index 000000000000..d8723d704337 --- /dev/null +++ b/test/embedding/test-embedding-builtin-code-cache.js @@ -0,0 +1,52 @@ +'use strict'; +// An embedder that bootstraps Node.js without a snapshot can generate a code +// cache for the builtins ahead of time (node::GenerateBuiltinCodeCache) and +// supply it at runtime (node::SetBuiltinCodeCache); the bootstrap and the +// per-context scripts then compile with that cache. Independently, it can ask +// Node.js not to serialize caches at runtime (kNoHarvestBuiltinCodeCache). +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const { spawnSyncAndAssert, spawnSyncAndExit } = require('../common/child_process'); +const fs = require('fs'); + +tmpdir.refresh(); +const embedtest = common.resolveBuiltBinary('embedtest'); +const cacheFile = tmpdir.resolve('builtins.codecache'); + +spawnSyncAndExit(embedtest, ['--', '--builtin-code-cache-create', cacheFile], { cwd: tmpdir.path }); +assert.ok(fs.statSync(cacheFile).size > 1024 * 1024); + +function compileLog(args) { + let log; + spawnSyncAndAssert( + embedtest, ['--', ...args, 'globalThis.ran = 40 + 2'], + { cwd: tmpdir.path, env: { ...process.env, NODE_DEBUG_NATIVE: 'CODE_CACHE' } }, + { stderr(output) { log = output; return true; } }); + return log; +} + +const without = compileLog([]); +assert.match(without, /Compiling internal\/bootstrap\/node without code cache/); + +const withCache = compileLog(['--builtin-code-cache', cacheFile]); +assert.doesNotMatch(withCache, /without code cache/); +assert.match(withCache, /Code cache of internal\/bootstrap\/node \(BufferNotOwned\) is accepted/); +assert.match(withCache, /Code cache of internal\/per_context\/primordials \(BufferNotOwned\) is accepted/); + +// Harvesting: by default a builtin compiled without a cache serializes one that +// worker threads then start from; with kNoHarvestBuiltinCodeCache they do not. +const workerScript = 'new (require("worker_threads").Worker)("", { eval: true })'; +function workerCompileLog(args) { + let log; + spawnSyncAndAssert( + embedtest, ['--', ...args, workerScript], + { cwd: tmpdir.path, env: { ...process.env, NODE_DEBUG_NATIVE: 'CODE_CACHE' } }, + { stderr(output) { log = output; return true; } }); + const worker = log.slice(log.lastIndexOf('Compiling internal/bootstrap/realm')); + assert.notStrictEqual(worker, log); + return worker; +} +assert.match(workerCompileLog([]), /Code cache of internal\/bootstrap\/node \(\w+\) is accepted/); +assert.match(workerCompileLog(['--no-harvest-builtin-code-cache']), + /Compiling internal\/bootstrap\/node without code cache/);