Repository navigation
src: improve compile cache performance and size #63861
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
anonrig
wants to merge
3
commits into
nodejs:main
Choose a base branch
from
anonrig:compile-cache-perf
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev
Previous commit
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).
- Loading branch information
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we do include dictionary files in our source tree, I'd say we should either include instructions for how to (at least approximately) re-create this file, or even generate it at build time entirely.
strings src/compile_cache_zstd.dictresults in a surprising amount of Node.js-core specificity, which seems surprising given that the commit that adds it claims it was trained on public npm packages.(
strings src/compile_cache_zstd.dictoutput in the fold)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@anonrig I'm going to mark this as un-resolved again, since there was no response here as far as I can tell
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I didn't see this up until now and didn't pressed the "resolve" button at all. Will address your concerns.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Github begged to differ:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AI "babysit" mode might be the root cause