Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Daniel Lemire
9f3e25689a making the script better. 2026-06-13 20:27:42 -04:00
Daniel Lemire
a60c80d06c add tools to reproduce the dict 2026-06-13 20:27:42 -04:00
Daniel Lemire
a6273b1f4a src: embed zstd dictionary for further compile cache size wins
Builds on the zstd compression in #63861 by embedding a small zstd
dictionary trained on a diverse corpus of real modules, so each
small/medium compile-cache entry compresses better. Per entry we keep
the smaller of the plain and dictionary-assisted frame, so the
dictionary only ever helps.

- Add src/compile_cache_zstd.dict (16 KiB). It is trained on V8 code
  caches harvested (via vm.compileFunction, the same shape the CJS
  loader produces) from a diverse corpus: bundled npm packages, lib/,
  tools/ and a few deps.
- Add tools/generate_compile_cache_dict.py and a node.gyp action that
  generates compile_cache_zstd_dict.h into SHARED_INTERMEDIATE_DIR at
  build time; no generated header is checked in. libnode include_dirs
  updated to pick it up.
- Prepare the CDict/DDict once per process (shared across all handlers
  and Workers, matching the lazy-context approach from #63861) and use
  them in Persist() and ReadCacheFile(). Persist() compresses the plain
  and dict frames into separate buffers and selects the smaller, so the
  written bytes and recorded size always agree. The dictionary is only
  tried for entries up to 256 KiB; larger blobs never benefit, so the
  second compression is skipped to avoid wasted work. Falls back to
  plain zstd if dictionary preparation fails.
- The dictionary is embedded in the binary because the compile cache
  must be usable early, portably, and without extra filesystem state.
- No on-disk format change: dict-assisted frames carry the dictID, plain
  frames carry none, and a single DDict decompresses both.
- Size, measured on data held out from training (per-entry min policy):
  diverse modules go from ~1.87x (plain zstd) to ~2.44x with the
  dictionary (~24% smaller on disk); on test/parallel, which is not in
  the training corpus at all, ~1.74x -> ~2.22x (~22% smaller). A real
  end-to-end run (npm --version, ~70 modules) is ~15% smaller. Read
  time is unchanged and the extra write-time work is negligible.
- Add a multi-module write/read roundtrip test and a startup benchmark
  (standard createBenchmark harness).
2026-06-11 21:39:47 -04:00
Yagiz Nizipli
f427fb358c
src: reuse zstd contexts in compile cache
Creating and freeing a zstd context for every cache file costs more
than the (de)compression itself for small caches. Lazily create one
decompression context on the handler and reuse it across reads, and
share one compression context across all entries in Persist().

Co-authored-by: Grok <grok@x.ai>
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
2026-06-11 18:40:07 -04:00
Yagiz Nizipli
fb86fb925a
src: improve compile cache performance and size
Improve the compile cache by:

- Reading cache files with a single exactly-sized read using the file
  size from fstat instead of reading into an exponentially growing
  buffer, which previously cost O(log N) syscalls and allocations and
  about 2N bytes of copying per file.
- Compressing the cache content on disk with zstd at level 1, falling
  back to raw storage when the data is not compressible. This shrinks
  cache directories by about 2-4x. The magic number is bumped so that
  files in the old format are discarded as cache misses and then
  overwritten in place.
- Handing the cache to V8 through a non-owning CachedData wrapper
  instead of copying the whole buffer on every cache hit.

Corrupted cache files keep degrading to silent cache misses and are
regenerated, now covered by a regression test.

Co-authored-by: Grok <grok@x.ai>
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
2026-06-11 18:38:41 -04:00
11 changed files with 760 additions and 68 deletions

View file

@ -0,0 +1,72 @@
'use strict';
// Startup benchmark for the compile cache (including the zstd dictionary).
// Compares no-cache / cold-cache / warm-cache for two workloads:
// big - one large module (the typescript.js fixture)
// many - many small modules (generated here, side-effect-free)
// The modules are generated into a temp dir so the benchmark is self-contained
// and reproducible, and never executes unrelated code.
const common = require('../common.js');
const { spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const bench = common.createBenchmark(main, {
workload: ['big', 'many'],
cache: ['none', 'cold', 'warm'],
n: [30],
});
const BIG = path.resolve(__dirname, '../../test/fixtures/snapshot/typescript.js');
// Generate `count` small, side-effect-free modules and return the require()
// code that loads them all in one child.
function makeManyModules(dir, count) {
fs.mkdirSync(dir, { recursive: true });
const reqs = [];
for (let i = 0; i < count; i++) {
const file = path.join(dir, `mod-${i}.js`);
fs.writeFileSync(
file,
`'use strict';\n` +
`module.exports = function value${i}(a, b) {\n` +
` const sum = a + b + ${i};\n` +
` return { id: ${i}, sum, label: 'module-${i}' };\n` +
`};\n`);
reqs.push(`require(${JSON.stringify(file)});`);
}
return reqs.join('');
}
function run(cmd, args, cacheDir) {
const env = { ...process.env };
if (cacheDir) env.NODE_COMPILE_CACHE = cacheDir;
else delete env.NODE_COMPILE_CACHE;
const child = spawnSync(cmd, args, { env, stdio: 'ignore' });
if (child.error) throw child.error;
}
function main({ n, workload, cache }) {
const cmd = process.execPath || process.argv[0];
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-bench-'));
const args = workload === 'big' ?
[BIG] :
['-e', makeManyModules(path.join(tmp, 'mods'), 120)];
const cacheDir = cache === 'none' ? null : path.join(tmp, 'cache');
try {
if (cache === 'warm') run(cmd, args, cacheDir); // populate once
bench.start();
for (let i = 0; i < n; i++) {
if (cache === 'cold' && cacheDir) {
fs.rmSync(cacheDir, { recursive: true, force: true });
}
run(cmd, args, cacheDir);
}
bench.end(n);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}

View file

@ -1110,6 +1110,22 @@
'<@(linked_module_files)',
],
},
{
'action_name': 'generate_compile_cache_zstd_dict',
'inputs': [
'src/compile_cache_zstd.dict',
'tools/generate_compile_cache_dict.py',
],
'outputs': [
'<(SHARED_INTERMEDIATE_DIR)/compile_cache_zstd_dict.h',
],
'action': [
'<(python)',
'tools/generate_compile_cache_dict.py',
'src/compile_cache_zstd.dict',
'<@(_outputs)',
],
},
],
}, # node_base
{
@ -1123,6 +1139,7 @@
'src',
'deps/v8/include',
'deps/uv/include',
'<(SHARED_INTERMEDIATE_DIR)', # for compile_cache_zstd_dict.h etc.
],
'dependencies': [

View file

@ -1,4 +1,6 @@
#include "compile_cache.h"
#include <array>
#include <memory>
#include <string>
#include "debug_utils-inl.h"
#include "env-inl.h"
@ -8,6 +10,11 @@
#include "path.h"
#include "util.h"
#include "zlib.h"
#include "zstd.h"
// kCompileCacheZstdDict + kCompileCacheZstdDictSize come from the header
// generated at build time by the GYP action (from src/compile_cache_zstd.dict).
// The include directory (SHARED_INTERMEDIATE_DIR) is added by node.gyp.
#include "compile_cache_zstd_dict.h"
#ifdef NODE_IMPLEMENTS_POSIX_CREDENTIALS
#include <unistd.h> // getuid
@ -25,6 +32,29 @@ using v8::ScriptCompiler;
using v8::String;
namespace {
// The compile-cache zstd dictionary is immutable and embedded in the binary,
// so the prepared CDict/DDict are created once and shared across all handlers
// (and all Environments/Workers) instead of per handler. They live for the
// lifetime of the process. Returns nullptr if preparation fails, in which
// case callers fall back to plain (dictionary-less) zstd.
ZSTD_CDict* GetCompileCacheCDict() {
static ZSTD_CDict* cdict =
ZSTD_createCDict(kCompileCacheZstdDict, kCompileCacheZstdDictSize, 1);
return cdict;
}
ZSTD_DDict* GetCompileCacheDDict() {
static ZSTD_DDict* ddict =
ZSTD_createDDict(kCompileCacheZstdDict, kCompileCacheZstdDictSize);
return ddict;
}
// The dictionary only helps small/medium caches; for larger inputs zstd's own
// adaptive model dominates and the dictionary never wins, so we skip the
// (otherwise wasted) second compression above this raw size. Decompression is
// unaffected: a single DDict decodes both dict-assisted and plain frames.
constexpr uint32_t kCompileCacheDictMaxRawSize = 256 * 1024;
std::string Uint32ToHex(uint32_t crc) {
std::string str;
str.reserve(8);
@ -75,18 +105,21 @@ inline void CompileCacheHandler::Debug(const char* format,
}
}
ScriptCompiler::CachedData* CompileCacheEntry::CopyCache() const {
ScriptCompiler::CachedData* CompileCacheEntry::WrapCache() const {
DCHECK_NOT_NULL(cache);
int cache_size = cache->length;
uint8_t* data = new uint8_t[cache_size];
memcpy(data, cache->data, cache_size);
// The returned CachedData does not own the buffer - it's a view into
// the buffer owned by this entry, which outlives the synchronous
// consumption of the cache during compilation, so no copy is necessary.
return new ScriptCompiler::CachedData(
data, cache_size, ScriptCompiler::CachedData::BufferOwned);
cache->data, cache->length, ScriptCompiler::CachedData::BufferNotOwned);
}
// Used for identifying and verifying a file is a compile cache file.
// See comments in CompileCacheHandler::Persist().
constexpr uint32_t kCacheMagicNumber = 0x8adfdbb2;
// The last byte is bumped whenever the format of the cache file changes
// so that files in an older format are discarded as cache misses and
// then overwritten with the new format.
constexpr uint32_t kCacheMagicNumber = 0x8adfdbb3;
const char* CompileCacheEntry::type_name() const {
switch (type) {
@ -124,10 +157,21 @@ void CompileCacheHandler::ReadCacheFile(CompileCacheEntry* entry) {
uv_fs_req_cleanup(&close_req);
});
// Get the file size upfront so that the cache can be read with a single
// exactly-sized read, and truncated or trailing data can be detected
// without additional read attempts.
int err = uv_fs_fstat(nullptr, &req, file, nullptr);
if (err < 0) {
Debug("fstat failed, %s\n", uv_strerror(err));
return;
}
uint64_t file_size = req.statbuf.st_size;
uv_fs_req_cleanup(&req);
// Read the headers.
std::vector<uint32_t> headers(kHeaderCount);
uv_buf_t headers_buf = uv_buf_init(reinterpret_cast<char*>(headers.data()),
kHeaderCount * sizeof(uint32_t));
std::array<uint32_t, kHeaderCount> headers;
uv_buf_t headers_buf =
uv_buf_init(reinterpret_cast<char*>(headers.data()), kHeaderSize);
const int r = uv_fs_read(nullptr, &req, file, &headers_buf, 1, 0, nullptr);
if (r != static_cast<int>(headers_buf.len)) {
Debug("reading header failed, bytes read %d", r);
@ -137,13 +181,15 @@ void CompileCacheHandler::ReadCacheFile(CompileCacheEntry* entry) {
Debug("\n");
return;
}
uv_fs_req_cleanup(&req);
Debug("[%d %d %d %d %d]...",
Debug("[%d %d %d %d %d %d]...",
headers[kMagicNumberOffset],
headers[kCodeSizeOffset],
headers[kCacheSizeOffset],
headers[kCodeHashOffset],
headers[kCacheHashOffset]);
headers[kCacheHashOffset],
headers[kCacheRawSizeOffset]);
if (headers[kMagicNumberOffset] != kCacheMagicNumber) {
Debug("magic number mismatch: expected %d, actual %d\n",
@ -166,50 +212,56 @@ void CompileCacheHandler::ReadCacheFile(CompileCacheEntry* entry) {
return;
}
// Read the cache, grow the buffer exponentially whenever it fills up.
size_t offset = headers_buf.len;
size_t capacity = 4096; // Initial buffer capacity
uint32_t cache_size = headers[kCacheSizeOffset];
uint32_t raw_size = headers[kCacheRawSizeOffset];
// Check the cache size. The headers were read successfully, so
// file_size >= kHeaderSize here. The file must contain exactly the
// headers followed by cache_size bytes of cache content.
if (file_size - kHeaderSize != cache_size) {
Debug("cache size mismatch: expected %d, actual %d\n",
cache_size,
file_size - kHeaderSize);
return;
}
// The cache content is stored uncompressed when cache_size == raw_size,
// and zstd-compressed when cache_size < raw_size (see
// CompileCacheHandler::Persist()). Anything else is invalid.
if (cache_size > raw_size) {
Debug(
"invalid cache size %d > uncompressed size %d\n", cache_size, raw_size);
return;
}
// Read the cache content in one go with an exactly-sized buffer,
// looping only in case of short reads.
std::unique_ptr<uint8_t[]> disk_data(new uint8_t[cache_size]);
size_t total_read = 0;
uint8_t* buffer = new uint8_t[capacity];
while (true) {
// If there is not enough space to read more data, do a simple
// realloc here (we don't actually realloc because V8 requires
// the underlying buffer to be delete[]-able).
if (total_read == capacity) {
size_t new_capacity = capacity * 2;
auto* new_buffer = new uint8_t[new_capacity];
memcpy(new_buffer, buffer, capacity);
delete[] buffer;
buffer = new_buffer;
capacity = new_capacity;
}
uv_buf_t iov = uv_buf_init(reinterpret_cast<char*>(buffer + total_read),
capacity - total_read);
int bytes_read =
uv_fs_read(nullptr, &req, file, &iov, 1, offset + total_read, nullptr);
while (total_read < cache_size) {
uv_buf_t iov =
uv_buf_init(reinterpret_cast<char*>(disk_data.get() + total_read),
cache_size - total_read);
int bytes_read = uv_fs_read(
nullptr, &req, file, &iov, 1, kHeaderSize + total_read, nullptr);
if (req.result < 0) { // Error.
// req will be cleaned up by scope leave.
delete[] buffer;
Debug(" %s\n", uv_strerror(req.result));
return;
}
uv_fs_req_cleanup(&req);
if (bytes_read <= 0) {
break;
if (bytes_read == 0) { // Unexpected EOF - the file shrank under us.
Debug("cache size mismatch: expected %d, actual %d\n",
cache_size,
total_read);
return;
}
total_read += bytes_read;
}
// Check the cache size and hash.
if (headers[kCacheSizeOffset] != total_read) {
Debug("cache size mismatch: expected %d, actual %d\n",
headers[kCacheSizeOffset],
total_read);
return;
}
uint32_t cache_hash = GetHash(reinterpret_cast<char*>(buffer), total_read);
// Check the cache hash of the on-disk content before decompressing.
uint32_t cache_hash =
GetHash(reinterpret_cast<char*>(disk_data.get()), cache_size);
if (headers[kCacheHashOffset] != cache_hash) {
Debug("cache hash mismatch: expected %d, actual %d\n",
headers[kCacheHashOffset],
@ -217,9 +269,58 @@ void CompileCacheHandler::ReadCacheFile(CompileCacheEntry* entry) {
return;
}
entry->cache.reset(new ScriptCompiler::CachedData(
buffer, total_read, ScriptCompiler::CachedData::BufferOwned));
Debug(" success, size=%d\n", total_read);
if (cache_size == raw_size) {
// Stored uncompressed - hand the buffer to V8 directly.
entry->cache.reset(new ScriptCompiler::CachedData(
disk_data.release(),
raw_size,
ScriptCompiler::CachedData::BufferOwned));
} else {
// Cross-check the content size embedded in the zstd frame before
// allocating, in case the headers are corrupted.
unsigned long long content_size = // NOLINT(runtime/int)
ZSTD_getFrameContentSize(disk_data.get(), cache_size);
if (content_size != raw_size) {
Debug("uncompressed size mismatch: expected %d, actual %d\n",
raw_size,
content_size);
return;
}
// Lazily create the decompression context on first use and reuse it
// for subsequent reads - recreating its workspace for every file
// costs more than the decompression itself for small caches.
if (zstd_dctx_ == nullptr && (zstd_dctx_ = ZSTD_createDCtx()) == nullptr) {
Debug("failed to create zstd context\n");
return;
}
// Decompress directly into the buffer handed to V8. The embedded
// dictionary is referenced via a shared, prepared DDict; plain frames
// (which carry no dictID) decompress correctly with it as well.
std::unique_ptr<uint8_t[]> raw_data(new uint8_t[raw_size]);
ZSTD_DDict* ddict = GetCompileCacheDDict();
size_t decompressed_size;
if (ddict != nullptr) {
decompressed_size = ZSTD_decompress_usingDDict(
zstd_dctx_, raw_data.get(), raw_size, disk_data.get(), cache_size,
ddict);
} else {
decompressed_size = ZSTD_decompressDCtx(
zstd_dctx_, raw_data.get(), raw_size, disk_data.get(), cache_size);
}
if (ZSTD_isError(decompressed_size)) {
Debug("decompression failed: %s\n", ZSTD_getErrorName(decompressed_size));
return;
}
if (decompressed_size != raw_size) {
Debug("decompressed size mismatch: expected %d, actual %d\n",
raw_size,
decompressed_size);
return;
}
entry->cache.reset(new ScriptCompiler::CachedData(
raw_data.release(), raw_size, ScriptCompiler::CachedData::BufferOwned));
}
Debug(" success, size=%d\n", raw_size);
}
static std::string GetRelativePath(std::string_view path,
@ -280,11 +381,18 @@ CompileCacheEntry* CompileCacheHandler::GetOrInsert(Local<String> code,
return loaded->second.get();
}
// If the code hash mismatches, the code has changed, discard the stale entry
// and create a new one.
auto emplaced =
compiler_cache_store_.emplace(key, std::make_unique<CompileCacheEntry>());
auto* result = emplaced.first->second.get();
// If the code hash mismatches, the code has changed, reset the stale
// entry in place. Otherwise insert a new one.
CompileCacheEntry* result;
if (loaded != compiler_cache_store_.end()) {
result = loaded->second.get();
result->refreshed = false;
result->persisted = false;
} else {
result = compiler_cache_store_
.emplace(key, std::make_unique<CompileCacheEntry>())
.first->second.get();
}
result->code_hash = code_hash;
result->code_size = code_utf8.length();
@ -391,6 +499,16 @@ void CompileCacheHandler::Persist() {
// finished. In that case, the off-thread writes should finish long
// before any attempt of flushing is made so the method would then only
// incur a negligible overhead from thread synchronization.
// The compression context is created lazily when there is anything to
// compress and reused for all the entries in this invocation.
ZSTD_CCtx* cctx = nullptr;
auto cleanup_cctx = OnScopeLeave([&cctx]() {
if (cctx != nullptr) {
ZSTD_freeCCtx(cctx);
}
});
for (auto& pair : compiler_cache_store_) {
auto* entry = pair.second.get();
const char* type_name = entry->type_name();
@ -418,18 +536,71 @@ void CompileCacheHandler::Persist() {
DCHECK_EQ(entry->cache->buffer_policy,
ScriptCompiler::CachedData::BufferOwned);
char* cache_ptr =
char* raw_ptr =
reinterpret_cast<char*>(const_cast<uint8_t*>(entry->cache->data));
uint32_t cache_size = static_cast<uint32_t>(entry->cache->length);
uint32_t raw_size = static_cast<uint32_t>(entry->cache->length);
// Compress the cache with zstd to reduce the size on disk. Compression
// level 1 prioritizes speed - persistence usually happens on process
// shutdown and should add as little overhead as possible. If the data
// is not compressible, store it uncompressed, which is indicated by
// the cache size being equal to the uncompressed size in the headers.
//
// We also try the embedded trained dictionary and keep whichever frame is
// smaller (still subject to the "only store if < raw" policy). The
// dictionary mainly helps the small/medium caches that dominate real
// compile cache usage; for inputs where plain zstd already wins we keep
// the plain frame.
char* cache_ptr = raw_ptr;
uint32_t cache_size = raw_size;
std::unique_ptr<uint8_t[]> compressed;
std::unique_ptr<uint8_t[]> compressed_dict;
if (cctx != nullptr || (cctx = ZSTD_createCCtx()) != nullptr) {
size_t compressed_bound = ZSTD_compressBound(raw_size);
compressed.reset(new uint8_t[compressed_bound]);
size_t compressed_size = ZSTD_compressCCtx(
cctx, compressed.get(), compressed_bound, raw_ptr, raw_size, 1);
char* best_ptr = reinterpret_cast<char*>(compressed.get());
// Only attempt the dictionary for small/medium entries (see
// kCompileCacheDictMaxRawSize); for large blobs it never wins and the
// extra compression would be wasted work.
ZSTD_CDict* cdict = raw_size <= kCompileCacheDictMaxRawSize
? GetCompileCacheCDict()
: nullptr;
if (cdict != nullptr) {
// Compress into a separate buffer so the selected frame's bytes and
// size always stay in sync (the plain buffer is left untouched).
compressed_dict.reset(new uint8_t[compressed_bound]);
size_t dict_size = ZSTD_compress_usingCDict(
cctx, compressed_dict.get(), compressed_bound, raw_ptr, raw_size,
cdict);
if (!ZSTD_isError(dict_size) &&
(ZSTD_isError(compressed_size) || dict_size < compressed_size)) {
compressed_size = dict_size;
best_ptr = reinterpret_cast<char*>(compressed_dict.get());
}
}
if (!ZSTD_isError(compressed_size) && compressed_size < raw_size) {
cache_ptr = best_ptr;
cache_size = static_cast<uint32_t>(compressed_size);
}
}
Debug("[compile cache] compressed cache for %s %s: %d -> %d bytes\n",
type_name,
entry->source_filename,
raw_size,
cache_size);
uint32_t cache_hash = GetHash(cache_ptr, cache_size);
// Generating headers.
std::vector<uint32_t> headers(kHeaderCount);
std::array<uint32_t, kHeaderCount> headers;
headers[kMagicNumberOffset] = kCacheMagicNumber;
headers[kCodeSizeOffset] = entry->code_size;
headers[kCacheSizeOffset] = cache_size;
headers[kCodeHashOffset] = entry->code_hash;
headers[kCacheHashOffset] = cache_hash;
headers[kCacheRawSizeOffset] = raw_size;
// Generate the temporary filename.
// The temporary file should be placed in a location like:
@ -459,7 +630,7 @@ void CompileCacheHandler::Persist() {
Debug(" -> %s\n", mkstemp_req.path);
Debug("[compile cache] writing cache for %s %s to temporary file %s [%d "
"%d %d "
"%d %d]...",
"%d %d %d]...",
type_name,
entry->source_filename,
mkstemp_req.path,
@ -467,12 +638,13 @@ void CompileCacheHandler::Persist() {
headers[kCodeSizeOffset],
headers[kCacheSizeOffset],
headers[kCodeHashOffset],
headers[kCacheHashOffset]);
headers[kCacheHashOffset],
headers[kCacheRawSizeOffset]);
// Write to the temporary file.
uv_buf_t headers_buf = uv_buf_init(reinterpret_cast<char*>(headers.data()),
headers.size() * sizeof(uint32_t));
uv_buf_t data_buf = uv_buf_init(cache_ptr, entry->cache->length);
uv_buf_t headers_buf =
uv_buf_init(reinterpret_cast<char*>(headers.data()), kHeaderSize);
uv_buf_t data_buf = uv_buf_init(cache_ptr, cache_size);
uv_buf_t bufs[] = {headers_buf, data_buf};
uv_fs_t write_req;
@ -529,6 +701,12 @@ CompileCacheHandler::CompileCacheHandler(Environment* env)
is_debug_(
env->enabled_debug_list()->enabled(DebugCategory::COMPILE_CACHE)) {}
CompileCacheHandler::~CompileCacheHandler() {
if (zstd_dctx_ != nullptr) {
ZSTD_freeDCtx(zstd_dctx_);
}
}
// Directory structure:
// - Compile cache directory (from NODE_COMPILE_CACHE)
// - $NODE_VERSION-$ARCH-$CACHE_DATA_VERSION_TAG-$UID

View file

@ -10,6 +10,8 @@
#include <unordered_map>
#include "v8.h"
struct ZSTD_DCtx_s;
namespace node {
class Environment;
@ -36,9 +38,11 @@ struct CompileCacheEntry {
bool refreshed = false;
bool persisted = false;
// Copy the cache into a new store for V8 to consume. Caller takes
// ownership.
v8::ScriptCompiler::CachedData* CopyCache() const;
// Wrap the cache into a non-owning CachedData for V8 to consume.
// The caller takes ownership of the returned wrapper object, while
// the underlying buffer remains owned by this entry and must outlive
// the consumption of the wrapper.
v8::ScriptCompiler::CachedData* WrapCache() const;
const char* type_name() const;
};
@ -65,6 +69,7 @@ enum class EnableOption : uint8_t { DEFAULT, PORTABLE };
class CompileCacheHandler {
public:
explicit CompileCacheHandler(Environment* env);
~CompileCacheHandler();
CompileCacheEnableResult Enable(Environment* env,
const std::string& dir,
EnableOption option = EnableOption::DEFAULT);
@ -99,7 +104,9 @@ class CompileCacheHandler {
static constexpr size_t kCacheSizeOffset = 2;
static constexpr size_t kCodeHashOffset = 3;
static constexpr size_t kCacheHashOffset = 4;
static constexpr size_t kHeaderCount = 5;
static constexpr size_t kCacheRawSizeOffset = 5;
static constexpr size_t kHeaderCount = 6;
static constexpr size_t kHeaderSize = kHeaderCount * sizeof(uint32_t);
v8::Isolate* isolate_ = nullptr;
bool is_debug_ = false;
@ -109,6 +116,9 @@ class CompileCacheHandler {
EnableOption portable_ = EnableOption::DEFAULT;
std::unordered_map<uint32_t, std::unique_ptr<CompileCacheEntry>>
compiler_cache_store_;
// Lazily created zstd decompression context, reused across cache reads
// to avoid recreating its workspace for every file.
ZSTD_DCtx_s* zstd_dctx_ = nullptr;
};
} // namespace node

BIN
src/compile_cache_zstd.dict Normal file

Binary file not shown.

View file

@ -521,8 +521,9 @@ MaybeLocal<Module> ModuleWrap::CompileSourceTextModule(
}
if (cache_entry != nullptr && cache_entry->cache != nullptr) {
// source will take ownership of cached_data.
cached_data = cache_entry->CopyCache();
// source only takes ownership of the cached_data wrapper - the
// underlying buffer is still owned by the compile cache entry.
cached_data = cache_entry->WrapCache();
}
ScriptCompiler::Source source(source_text, origin, cached_data);

View file

@ -1675,8 +1675,9 @@ static MaybeLocal<Function> CompileFunctionForCJSLoader(
code, filename, CachedCodeType::kCommonJS);
}
if (cache_entry != nullptr && cache_entry->cache != nullptr) {
// source will take ownership of cached_data.
cached_data = cache_entry->CopyCache();
// source only takes ownership of the cached_data wrapper - the
// underlying buffer is still owned by the compile cache entry.
cached_data = cache_entry->WrapCache();
}
ScriptCompiler::Source source(code, origin, cached_data);

View file

@ -0,0 +1,128 @@
'use strict';
// This tests that NODE_COMPILE_CACHE gracefully discards corrupted
// cache files and regenerates them.
require('../common');
const { spawnSyncAndAssert } = require('../common/child_process');
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const tmpdir = require('../common/tmpdir');
// Offsets into the cache file headers (see src/compile_cache.h).
const kHeaderSize = 6 * 4;
const kCacheRawSizeOffset = 5 * 4;
function readU32(buf, offset) {
return os.endianness() === 'LE' ?
buf.readUInt32LE(offset) : buf.readUInt32BE(offset);
}
function writeU32(buf, value, offset) {
if (os.endianness() === 'LE') {
buf.writeUInt32LE(value, offset);
} else {
buf.writeUInt32BE(value, offset);
}
}
tmpdir.refresh();
const dir = tmpdir.resolve('.compile_cache_dir');
const script = tmpdir.resolve('script.js');
fs.writeFileSync(script, 'const foo = 1;', 'utf-8');
const env = {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir,
};
function getCacheFile() {
const subdirs = fs.readdirSync(dir);
assert.strictEqual(subdirs.length, 1);
const entries = fs.readdirSync(path.join(dir, subdirs[0]));
assert.strictEqual(entries.length, 1);
return path.join(dir, subdirs[0], entries[0]);
}
// Runs the script and expects the corrupted cache to be discarded
// with the given debug message and then regenerated.
function expectRecovery(mismatchRE) {
spawnSyncAndAssert(
process.execPath,
[script],
{ env, cwd: tmpdir.path },
{
stderr(output) {
console.log(output); // Logging for debugging.
assert.match(output, mismatchRE);
assert.match(output, /writing cache for .*script\.js.*success/);
return true;
}
});
}
// Warm the cache.
spawnSyncAndAssert(
process.execPath,
[script],
{ env, cwd: tmpdir.path },
{
stderr(output) {
console.log(output); // Logging for debugging.
assert.match(output, /writing cache for .*script\.js.*success/);
return true;
}
});
const cacheFile = getCacheFile();
assert(fs.readFileSync(cacheFile).length > kHeaderSize);
{
// Corrupt the magic number.
const data = fs.readFileSync(cacheFile);
for (let i = 0; i < 4; i++) data[i] ^= 0xff;
fs.writeFileSync(cacheFile, data);
expectRecovery(
/reading cache from .* for CommonJS .*script\.js.*magic number mismatch/);
}
{
// Truncate the cache content.
const data = fs.readFileSync(cacheFile);
fs.writeFileSync(cacheFile, data.subarray(0, data.length - 3));
expectRecovery(
/reading cache from .* for CommonJS .*script\.js.*cache size mismatch/);
}
{
// Flip a byte in the middle of the cache content.
const data = fs.readFileSync(cacheFile);
data[kHeaderSize + Math.floor((data.length - kHeaderSize) / 2)] ^= 0xff;
fs.writeFileSync(cacheFile, data);
expectRecovery(
/reading cache from .* for CommonJS .*script\.js.*cache hash mismatch/);
}
{
// Corrupt the uncompressed size field in the headers.
const data = fs.readFileSync(cacheFile);
writeU32(data, readU32(data, kCacheRawSizeOffset) + 1, kCacheRawSizeOffset);
fs.writeFileSync(cacheFile, data);
expectRecovery(
/reading cache from .* for CommonJS .*script\.js.*uncompressed size mismatch/);
}
// After the last recovery the cache should be consumed just fine.
spawnSyncAndAssert(
process.execPath,
[script],
{ env, cwd: tmpdir.path },
{
stderr(output) {
console.log(output); // Logging for debugging.
assert.match(output, /cache for .*script\.js was accepted/);
return true;
}
});

View file

@ -64,3 +64,83 @@ const path = require('path');
}
});
}
// Exercise the dictionary-compressed path (added on top of #63861) for many
// small modules, which is where the embedded dictionary helps most. We write
// the cache, then read it back and assert every entry is accepted - this
// proves each dict-compressed frame decompresses to exactly the bytes that
// were persisted.
{
tmpdir.refresh();
const dir = tmpdir.resolve('.compile_cache_dir');
// Generate a handful of small modules so the dictionary path is exercised.
const count = 8;
const modules = [];
for (let i = 0; i < count; i++) {
const file = tmpdir.resolve(`mod-${i}.js`);
fs.writeFileSync(
file,
`'use strict';\n` +
`module.exports = function value${i}(a, b) {\n` +
` const sum = a + b + ${i};\n` +
` return { id: ${i}, sum, label: 'module-${i}' };\n` +
`};\n`);
modules.push(file);
}
const reqCode = modules.map((m) => `require(${JSON.stringify(m)});`).join('');
// First run writes the cache for every module.
spawnSyncAndAssert(
process.execPath,
['-e', reqCode],
{
env: {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir
},
cwd: tmpdir.path
},
{
stderr(output) {
for (const m of modules) {
const name = path.basename(m).replace(/[.]/g, '\\.');
assert.match(output, new RegExp(`writing cache for .*${name}.*success`));
}
return true;
}
});
const cacheDirs = fs.readdirSync(dir);
assert.strictEqual(cacheDirs.length, 1);
// At least one entry per module (the `-e` runner is cached too).
const entries = fs.readdirSync(path.join(dir, cacheDirs[0]));
assert(entries.length >= count, `expected >= ${count} entries, got ${entries.length}`);
// Second run reads every cached entry back; "was accepted" only happens when
// the decompressed bytes match the freshly produced in-memory cache, so this
// is a full roundtrip check of the dictionary-compressed entries.
spawnSyncAndAssert(
process.execPath,
['-e', reqCode],
{
env: {
...process.env,
NODE_DEBUG_NATIVE: 'COMPILE_CACHE',
NODE_COMPILE_CACHE: dir
},
cwd: tmpdir.path
},
{
stderr(output) {
for (const m of modules) {
const name = path.basename(m).replace(/[.]/g, '\\.');
assert.match(
output,
new RegExp(`cache for .*${name} was accepted, keeping the in-memory entry`));
}
return true;
}
});
}

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python
"""Generate compile_cache_zstd_dict.h from a trained zstd .dict file.
Invoked by the GYP action in node.gyp at build time. Only the small binary
.dict (src/compile_cache_zstd.dict) is checked into the repository; the C
array it produces is generated into SHARED_INTERMEDIATE_DIR.
"""
import os
import sys
def main(dict_path, out_path):
with open(dict_path, 'rb') as f:
data = f.read()
lines = [
'// Generated by tools/generate_compile_cache_dict.py',
'// from %s' % os.path.basename(dict_path),
'// The .dict file is the source of truth; do not edit by hand.',
'',
'static const unsigned char kCompileCacheZstdDict[] = {',
]
for i in range(0, len(data), 12):
chunk = data[i:i + 12]
lines.append(' %s,' % ', '.join('0x%02x' % b for b in chunk))
lines.append('};')
lines.append('static const size_t kCompileCacheZstdDictSize = %d;' %
len(data))
with open(out_path, 'w') as f:
f.write('\n'.join(lines) + '\n')
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit('Usage: %s <input.dict> <output.h>' % sys.argv[0])
main(sys.argv[1], sys.argv[2])

View file

@ -0,0 +1,168 @@
#!/usr/bin/env node
// =============================================================================
// train_compile_cache_dict.mjs
//
// Maintainer tool that regenerates src/compile_cache_zstd.dict, the zstd
// dictionary embedded in the node binary and used to shrink compile-cache
// entries on disk (see src/compile_cache.cc).
//
// -----------------------------------------------------------------------------
// What it does
// -----------------------------------------------------------------------------
// The node compile cache stores V8 code caches (the bytecode/metadata blob V8
// produces when it compiles a module) so later runs can skip recompilation.
// These blobs share a lot of structure across files, so we zstd-compress them
// with a trained dictionary to cut the cache's on-disk footprint.
//
// This script builds that dictionary end to end:
//
// 1. Walks a fixed in-tree corpus (CORPUS below) in sorted order.
// 2. For each .js file, harvests a V8 code cache via vm.compileFunction with
// produceCachedData — the same shape the CommonJS loader produces at
// runtime — and writes each blob to a temp directory.
// 3. Feeds all the harvested blobs to `zstd --train`, capping the result at
// MAXDICT (16 KiB) bytes.
// 4. Overwrites src/compile_cache_zstd.dict with the trained dictionary.
//
// The shipped src/compile_cache_zstd.dict is the source of truth; this script
// exists to document and reproduce exactly how that file was made, so a future
// maintainer can regenerate it (e.g. after a V8/corpus change) and review the
// resulting diff.
//
// -----------------------------------------------------------------------------
// Usage
// -----------------------------------------------------------------------------
// node tools/train_compile_cache_dict.mjs
//
// Run from anywhere (paths are resolved relative to this file). The output is
// written in place to src/compile_cache_zstd.dict; inspect the diff afterwards
// and commit it if it looks right. Progress is printed to stderr.
//
// Prerequisites:
// * the `zstd` CLI on PATH, version REQUIRED_ZSTD (matching deps/zstd).
// * a built node on PATH (this is the node you invoke it with).
//
// Note: --predictable is added automatically (see below) — you do not need to
// pass it yourself.
//
// -----------------------------------------------------------------------------
// Reproducibility
// -----------------------------------------------------------------------------
// The output is byte-for-byte stable only if all of the following are pinned:
//
// * node is run with --predictable. V8 otherwise randomizes the string hash
// seed per process, and that seed leaks into vm.compileFunction cachedData,
// so every harvest (and therefore every trained dictionary) would differ
// run-to-run even on the same machine. This script re-executes itself with
// --predictable if needed.
// * the corpus and its order are fixed (CORPUS below, walked sorted).
// * the node build is fixed: cachedData embeds the V8 version and build
// flags, so a different node produces a different (still valid) dictionary.
// * the zstd CLI matches deps/zstd (currently 1.5.7). ZDICT training defaults
// change between zstd releases; this script refuses to run on a mismatch.
//
// Training on --predictable caches is fine even though the runtime consumes
// non-predictable caches: the dictionary only supplies shared substrings, and
// Persist() keeps min(plain, dict) per entry, so a less-than-ideal dictionary
// can never make any entry larger.
// =============================================================================
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdtempSync, readdirSync,
rmSync } from 'node:fs';
import { join, relative, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'src', 'compile_cache_zstd.dict');
const MAXDICT = 16384;
const REQUIRED_ZSTD = '1.5.7'; // keep in sync with deps/zstd/lib/zstd.h
// Fixed corpus, relative to the repo root. Chosen to be diverse, always
// present in a checkout, and disjoint from the held-out measurement corpora
// (e.g. test/parallel) used to report size numbers.
const CORPUS = ['lib', 'tools', 'deps/npm/node_modules'];
// V8 randomizes the hash seed per process; re-exec under --predictable so the
// harvested caches — and the trained dictionary — are deterministic.
if (!process.execArgv.includes('--predictable')) {
const r = spawnSync(process.execPath,
['--predictable', fileURLToPath(import.meta.url), ...process.argv.slice(2)],
{ stdio: 'inherit' });
process.exit(r.status ?? 1);
}
function checkZstd() {
const r = spawnSync('zstd', ['--version'], { encoding: 'utf8' });
if (r.status !== 0) {
console.error('error: zstd CLI not found on PATH'); process.exit(1);
}
const m = r.stdout.match(/v(\d+\.\d+\.\d+)/);
if (!m || m[1] !== REQUIRED_ZSTD) {
console.error(`error: zstd ${REQUIRED_ZSTD} required (matching deps/zstd), ` +
`found ${m ? m[1] : 'unknown'}`);
process.exit(1);
}
}
const PARAMS = ['exports', 'require', 'module', '__filename', '__dirname'];
function* walk(dir) {
let ents;
try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of ents.sort((a, b) => (a.name < b.name ? -1 : 1))) {
const p = join(dir, e.name);
if (e.isDirectory()) yield* walk(p);
else if (e.isFile() && p.endsWith('.js')) yield p;
}
}
async function main() {
checkZstd();
const { default: vm } = await import('node:vm');
const files = [];
for (const root of CORPUS) for (const f of walk(join(ROOT, root))) files.push(f);
files.sort();
const samples = mkdtempSync(join(tmpdir(), 'cc-dict-'));
const sampleFiles = [];
let ok = 0;
let r;
try {
for (const f of files) {
let code;
try { code = readFileSync(f, 'utf8'); } catch { continue; }
try {
const fn = vm.compileFunction(code, PARAMS,
{ filename: f, produceCachedData: true });
const cd = fn.cachedData;
if (cd && cd.length > 0) {
const name = relative(ROOT, f).replace(/[\\/]/g, '_') + '.cache';
const out = join(samples, name);
writeFileSync(out, cd);
sampleFiles.push(out);
ok++;
}
} catch { /* skip modules V8 can't compile standalone */ }
}
sampleFiles.sort();
console.error(`harvested ${ok}/${files.length} code caches from ` +
`${CORPUS.join(', ')}`);
// One sample path per file is spread on argv; the fixed corpus (~1.4k
// files, a few hundred KB of paths) stays well under ARG_MAX.
r = spawnSync('zstd',
['--train', ...sampleFiles, `--maxdict=${MAXDICT}`, '-f', '-o', OUT],
{ stdio: ['ignore', 'ignore', 'inherit'] });
} finally {
rmSync(samples, { recursive: true, force: true });
}
if (r.status !== 0) { console.error('error: zstd --train failed'); process.exit(1); }
const size = readFileSync(OUT).length;
console.error(`wrote ${relative(ROOT, OUT)} (${size} bytes)`);
}
main().catch((e) => { console.error(e); process.exit(1); });