From 136d2adb73ad59107bf83748ce251b608aba23b1 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sun, 16 Aug 2026 20:45:18 +0000 Subject: [PATCH] src: let embedders supply a builtin code cache without a snapshot Contexts and Environments created from the built-in snapshot get the builtins' code cache from that snapshot. An embedder that bootstraps them from scratch (its own isolate and context, no EmbedderSnapshotData) has no way to provide one: every builtin the bootstrap touches, and the per-context scripts NewContext() runs, are compiled from source in every such process, and each of them then serializes a fresh cache (SaveCodeCache) that only a later worker thread would ever consume. Add a small public API for that case: - node::GenerateBuiltinCodeCache(context) compiles every builtin in a context of the right kind of isolate and returns the caches, for a build step that embeds them. - node::SetBuiltinCodeCache(entries) installs process-wide entries that every BuiltinLoader created afterwards starts with, i.e. each Environment's and the loader for the per-context scripts. Entries a snapshot provides still merge on top (RefreshCodeCache() now merges with insert_or_assign instead of assuming a single call). - ProcessInitializationFlags::kNoHarvestBuiltinCodeCache stops serializing caches for builtins compiled without one, for embedders that supply their own or never create workers. The default is unchanged because worker threads copy the harvested cache. embedtest gains --builtin-code-cache-create, --builtin-code-cache and --no-harvest-builtin-code-cache, and a test that generates a cache in one process, checks that the bootstrap and per-context scripts of another compile with it, and that a worker does or does not find a harvested cache depending on the flag. On x64 Linux embedtest's start-to-exit goes from ~64 to ~44 ms with a supplied cache; not harvesting alone saves ~5 ms on a snapshot-less start and is a no-op with the snapshot. Signed-off-by: Shelley Vohr --- src/node.cc | 4 + src/node.h | 35 +++++++ src/node_builtins.cc | 77 ++++++++++++++-- src/node_builtins.h | 14 +++ test/cctest/test_per_process.cc | 59 ++++++++++++ test/embedding/embedtest.cc | 92 ++++++++++++++++--- .../test-embedding-builtin-code-cache.js | 52 +++++++++++ 7 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 test/embedding/test-embedding-builtin-code-cache.js 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/);