diff --git a/src/api/environment.cc b/src/api/environment.cc index 3b94d860de1e..069a75dbf4b2 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -425,12 +425,23 @@ Environment* CreateEnvironment( EnvironmentFlags::Flags flags, ThreadId thread_id, std::unique_ptr inspector_parent_handle, - std::string_view thread_name) { + std::string_view thread_name, + const EmbedderBuiltinCodeCache* builtin_code_cache) { Isolate* isolate = isolate_data->isolate(); Isolate::Scope isolate_scope(isolate); HandleScope handle_scope(isolate); + if (builtin_code_cache != nullptr) { + auto check = builtin_code_cache->CompatibilityCheck(isolate); + if (check != v8::ScriptCompiler::CachedData::kSuccess) { + per_process::Debug(DebugCategory::CODE_CACHE, + "EmbedderBuiltinCodeCache rejected: %d\n", + static_cast(check)); + return nullptr; + } + } + const bool use_snapshot = context.IsEmpty(); const EnvSerializeInfo* env_snapshot_info = nullptr; if (use_snapshot) { @@ -449,6 +460,10 @@ Environment* CreateEnvironment( thread_id, thread_name); CHECK_NOT_NULL(env); + if (builtin_code_cache != nullptr) { + env->builtin_loader()->RefreshCodeCache( + GetBuiltinCodeCacheEntries(*builtin_code_cache->impl_)); + } if (use_snapshot) { context = Context::FromSnapshot(isolate, diff --git a/src/node.cc b/src/node.cc index 7d51cbd5d0c7..b0ab485f6907 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1274,6 +1274,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 e827a46e14dd..efa86766495a 100644 --- a/src/node.h +++ b/src/node.h @@ -234,6 +234,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 an EmbedderBuiltinCodeCache 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 @@ -686,6 +691,46 @@ struct InspectorParentHandle { virtual ~InspectorParentHandle() = default; }; +// Code cache for the built-in JavaScript of Environments that are bootstrapped +// rather than deserialized from a snapshot; pass to CreateEnvironment(). One +// instance can serve many Environments, which share its buffers. +class NODE_EXTERN EmbedderBuiltinCodeCache { + public: + struct Entry { + std::string id; // e.g. "internal/bootstrap/node" + std::unique_ptr data; + }; + explicit EmbedderBuiltinCodeCache(std::vector entries); + ~EmbedderBuiltinCodeCache(); + + // Compiles every built-in module in `context`, which must come from + // NewContext(), and returns their code caches; empty on failure. + static std::vector Generate(v8::Local context); + + // Whether the entries can be used in `isolate`; CreateEnvironment() returns + // nullptr for a cache that does not pass. + v8::ScriptCompiler::CachedData::CompatibilityCheckResult CompatibilityCheck( + v8::Isolate* isolate) const; + + EmbedderBuiltinCodeCache(const EmbedderBuiltinCodeCache&) = delete; + EmbedderBuiltinCodeCache& operator=(const EmbedderBuiltinCodeCache&) = delete; + + struct Impl; + + private: + std::unique_ptr impl_; + friend NODE_EXTERN Environment* CreateEnvironment( + IsolateData*, + v8::Local, + const std::vector&, + const std::vector&, + EnvironmentFlags::Flags, + ThreadId, + std::unique_ptr, + std::string_view, + const EmbedderBuiltinCodeCache*); +}; + // TODO(addaleax): Maybe move per-Environment options parsing here. // Returns nullptr when the Environment cannot be created e.g. there are // pending JavaScript exceptions. @@ -699,7 +744,8 @@ NODE_EXTERN Environment* CreateEnvironment( EnvironmentFlags::Flags flags = EnvironmentFlags::kDefaultFlags, ThreadId thread_id = {} /* allocates a thread id automatically */, std::unique_ptr inspector_parent_handle = {}, - std::string_view thread_name = {}); + std::string_view thread_name = {}, + const EmbedderBuiltinCodeCache* builtin_code_cache = nullptr); // Returns a handle that can be passed to `LoadEnvironment()`, making the // child Environment accessible to the inspector as if it were a Node.js Worker. diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 43a9a388c2ba..7d3bf30e100e 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -1,4 +1,6 @@ #include "node_builtins.h" +#include +#include #include "debug_utils-inl.h" #include "env-inl.h" #include "module_wrap.h" @@ -12,7 +14,6 @@ #include "v8-value.h" namespace node { -namespace builtins { using loader::HostDefinedOptions; using v8::Boolean; @@ -44,6 +45,16 @@ using v8::TryCatch; using v8::Undefined; using v8::Value; +namespace builtins { + +namespace { +std::atomic harvest_code_cache{true}; +} // namespace + +void BuiltinLoader::SetHarvestCodeCache(bool on) { + harvest_code_cache = on; +} + BuiltinLoader::BuiltinLoader() : config_(GetConfig()), code_cache_(std::make_shared()) { LoadJavaScriptSource(); @@ -422,6 +433,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 +605,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 +931,64 @@ void BuiltinLoader::RegisterExternalReferences( } } // namespace builtins + +struct EmbedderBuiltinCodeCache::Impl { + std::vector entries; +}; + +EmbedderBuiltinCodeCache::EmbedderBuiltinCodeCache(std::vector entries) + : impl_(std::make_unique()) { + impl_->entries.reserve(entries.size()); + for (Entry& e : entries) { + impl_->entries.push_back( + {std::move(e.id), + builtins::BuiltinCodeCacheData( + std::shared_ptr(std::move(e.data)))}); + } +} + +EmbedderBuiltinCodeCache::~EmbedderBuiltinCodeCache() = default; + +ScriptCompiler::CachedData::CompatibilityCheckResult +EmbedderBuiltinCodeCache::CompatibilityCheck(Isolate* isolate) const { + for (const builtins::CodeCacheInfo& info : impl_->entries) { + ScriptCompiler::CachedData probe( + info.data.data, + static_cast(info.data.length), + ScriptCompiler::CachedData::BufferNotOwned); + auto result = probe.CompatibilityCheck(isolate); + if (result != ScriptCompiler::CachedData::kSuccess) return result; + } + return ScriptCompiler::CachedData::kSuccess; +} + +std::vector EmbedderBuiltinCodeCache::Generate( + 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) { + uint8_t* copy = new uint8_t[info.data.length]; + memcpy(copy, info.data.data, info.data.length); + out.push_back({info.id, + std::make_unique( + copy, + static_cast(info.data.length), + ScriptCompiler::CachedData::BufferOwned)}); + } + return out; +} + +const std::vector& GetBuiltinCodeCacheEntries( + const EmbedderBuiltinCodeCache::Impl& impl) { + return impl.entries; +} + } // namespace node NODE_BINDING_PER_ISOLATE_INIT( diff --git a/src/node_builtins.h b/src/node_builtins.h index b51b85ff6f23..89fe7f5c1aa7 100644 --- a/src/node_builtins.h +++ b/src/node_builtins.h @@ -125,8 +125,16 @@ 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); + // 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< diff --git a/src/node_internals.h b/src/node_internals.h index 631a8d7ccdd9..3039b23ee1ad 100644 --- a/src/node_internals.h +++ b/src/node_internals.h @@ -45,6 +45,12 @@ struct sockaddr; namespace node { +namespace builtins { +struct CodeCacheInfo; +} +const std::vector& GetBuiltinCodeCacheEntries( + const EmbedderBuiltinCodeCache::Impl& impl); + namespace builtins { class BuiltinLoader; } diff --git a/test/cctest/test_per_process.cc b/test/cctest/test_per_process.cc index 7a6f53d56222..937e9cab54dd 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,22 @@ 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); +} + } // end namespace diff --git a/test/embedding/embedtest.cc b/test/embedding/embedtest.cc index 045c01211cf2..d77939e8dcc3 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,61 @@ 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 std::unique_ptr 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::EmbedderBuiltinCodeCache::Entry& e : entries) { + uint32_t idlen = u32(); + e.id.assign(p, idlen); + p += idlen; + uint32_t length = u32(); + e.data = std::make_unique( + reinterpret_cast(p), + static_cast(length), + v8::ScriptCompiler::CachedData::BufferNotOwned); + p += length; + } + return std::make_unique(std::move(entries)); +} + +static int WriteBuiltinCodeCache(v8::Local context, + const std::string& path) { + std::vector entries = + node::EmbedderBuiltinCodeCache::Generate(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::EmbedderBuiltinCodeCache::Entry& e : entries) { + u32(static_cast(e.id.size())); + fwrite(e.id.data(), 1, e.id.size(), fp); + u32(static_cast(e.data->length)); + fwrite(e.data->data, 1, e.data->length, fp); + } + fclose(fp); + return 0; +} + static int RunNodeInstance(MultiIsolatePlatform* platform, const std::vector& args, const std::vector& exec_args); @@ -79,19 +135,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()); @@ -141,6 +202,8 @@ int RunNodeInstance(MultiIsolatePlatform* platform, bool snapshot_as_file = false; std::optional snapshot_config; std::string snapshot_blob_path; + std::string code_cache_out_path; + std::unique_ptr builtin_code_cache; for (size_t i = 0; i < args.size(); ++i) { const std::string& arg = args[i]; if (arg == "--embedder-snapshot-create") { @@ -179,6 +242,14 @@ int RunNodeInstance(MultiIsolatePlatform* platform, } snapshot_config.value().base_blob = &base_blob; 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()); + builtin_code_cache = LoadBuiltinCodeCache(args[i + 1]); + i++; } else { filtered_args.push_back(arg); } @@ -231,6 +302,17 @@ int RunNodeInstance(MultiIsolatePlatform* platform, setup = CommonEnvironmentSetup::CreateForSnapshotting( platform, &errors, filtered_args, exec_args); } + } else if (builtin_code_cache) { + setup = CommonEnvironmentSetup::Create( + platform, + &errors, + filtered_args, + exec_args, + node::EnvironmentFlags::kDefaultFlags, + node::ThreadId{}, + std::unique_ptr{}, + std::string_view{}, + builtin_code_cache.get()); } else { setup = CommonEnvironmentSetup::Create( platform, &errors, filtered_args, exec_args); @@ -250,6 +332,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..0e75a2540d8b --- /dev/null +++ b/test/embedding/test-embedding-builtin-code-cache.js @@ -0,0 +1,51 @@ +'use strict'; +// An embedder that bootstraps Node.js without a snapshot can generate a code +// cache for the builtins ahead of time (EmbedderBuiltinCodeCache::Generate) +// and pass it to CreateEnvironment(); the bootstrap then compiles 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, spawnSyncAndExitWithoutError } = require('../common/child_process'); +const fs = require('fs'); + +tmpdir.refresh(); +const embedtest = common.resolveBuiltBinary('embedtest'); +const cacheFile = tmpdir.resolve('builtins.codecache'); + +spawnSyncAndExitWithoutError(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, /Compiling (?!internal\/per_context\/)\S+ without code cache/); +assert.match(withCache, /Code cache of internal\/bootstrap\/node \(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/);