Skip to content

Commit 7b3c411

Browse files
authored
feat: add code caching support MONGOSH-1334 (#39)
This speeds up mongosh startup time by 26.8 % when active (but will likely be mutually exclusive with snapshot support, once that is finished).
1 parent acb591a commit 7b3c411

File tree

7 files changed

+195
-40
lines changed

7 files changed

+195
-40
lines changed

.github/workflows/nodejs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
fail-fast: false
3737
matrix:
3838
node-version: [14.x, 16.x, 18.x, 19.x]
39-
shard: [1, 2, 3]
39+
shard: [1, 2, 3, 4]
4040
runs-on: windows-latest
4141
steps:
4242
- uses: actions/checkout@v2
@@ -61,4 +61,4 @@ jobs:
6161
- name: Sharded tests
6262
run: npm test -- -g "shard ${{ matrix.shard }}"
6363
- name: Unsharded tests
64-
run: npm test -- -g "shard [1-3]" -i
64+
run: npm test -- -g "shard [1-4]" -i

bin/boxednode.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const argv = require('yargs')
1313
alias: 't', type: 'string', demandOption: true, desc: 'Target executable file'
1414
})
1515
.option('node-version', {
16-
alias: 'n', type: 'string', desc: 'Node.js version or semver version range or tarball file url', default: '*'
16+
alias: 'n', type: 'string', desc: 'Node.js version or semver version range or .tar.gz file url', default: '*'
1717
})
1818
.option('configure-args', {
1919
alias: 'C', type: 'string', desc: 'Extra ./configure or vcbuild arguments, comma-separated'
@@ -27,9 +27,12 @@ const argv = require('yargs')
2727
.option('namespace', {
2828
alias: 'N', type: 'string', desc: 'Module identifier for the generated binary'
2929
})
30-
.options('use-legacy-default-uv-loop', {
30+
.option('use-legacy-default-uv-loop', {
3131
type: 'boolean', desc: 'Use the global singleton libuv event loop rather than a separate local one'
3232
})
33+
.option('use-code-cache', {
34+
alias: 'H', type: 'boolean', desc: 'Use Node.js code cache support to speed up startup'
35+
})
3336
.example('$0 -s myProject.js -t myProject.exe -n ^14.0.0',
3437
'Create myProject.exe from myProject.js using Node.js v14')
3538
.help()
@@ -46,7 +49,8 @@ const argv = require('yargs')
4649
configureArgs: (argv.C || '').split(',').filter(Boolean),
4750
makeArgs: (argv.M || '').split(',').filter(Boolean),
4851
namespace: argv.N,
49-
useLegacyDefaultUvLoop: argv.useLegacyDefaultUvLoop
52+
useLegacyDefaultUvLoop: argv.useLegacyDefaultUvLoop,
53+
useCodeCache: argv.H
5054
});
5155
} catch (err) {
5256
console.error(err);

resources/entry-point-trampoline.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const Module = require('module');
33
const vm = require('vm');
44
const path = require('path');
5+
const assert = require('assert');
56
const {
67
requireMappings,
78
enableBindingsPatch
@@ -49,7 +50,7 @@ if (enableBindingsPatch) {
4950
});
5051
}
5152

52-
module.exports = (src) => {
53+
module.exports = (src, codeCacheMode, codeCache) => {
5354
const __filename = process.execPath;
5455
const __dirname = path.dirname(process.execPath);
5556
const innerRequire = Module.createRequire(__filename);
@@ -68,6 +69,7 @@ module.exports = (src) => {
6869
Object.setPrototypeOf(require, Object.getPrototypeOf(innerRequire));
6970

7071
process.argv.unshift(__filename);
72+
process.boxednode = {};
7173

7274
const module = {
7375
exports,
@@ -77,10 +79,23 @@ module.exports = (src) => {
7779
path: __dirname,
7880
require
7981
};
80-
vm.compileFunction(src, [
82+
const mainFunction = vm.compileFunction(src, [
8183
'__filename', '__dirname', 'require', 'exports', 'module'
8284
], {
83-
filename: __filename
84-
})(__filename, __dirname, require, exports, module);
85+
filename: __filename,
86+
cachedData: codeCache.length > 0 ? codeCache : undefined,
87+
produceCachedData: codeCacheMode === 'generate'
88+
});
89+
if (codeCacheMode === 'generate') {
90+
assert.strictEqual(mainFunction.cachedDataProduced, true);
91+
process.stdout.write(mainFunction.cachedData);
92+
return;
93+
}
94+
95+
process.boxednode.hasCodeCache = codeCache.length > 0;
96+
// https://github.com/nodejs/node/pull/46320
97+
process.boxednode.rejectedCodeCache = mainFunction.cachedDataRejected;
98+
99+
mainFunction(__filename, __dirname, require, exports, module);
85100
return module.exports;
86101
};

resources/main-template.cc

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#include "node.h"
77
#include "node_api.h"
88
#include "uv.h"
9+
#include "brotli/decode.h"
910
#if HAVE_OPENSSL
1011
#include <openssl/err.h>
1112
#include <openssl/ssl.h>
@@ -43,6 +44,7 @@ void TearDownOncePerProcess();
4344
#endif
4445
namespace boxednode {
4546
Local<String> GetBoxednodeMainScriptSource(Isolate* isolate);
47+
Local<Uint8Array> GetBoxednodeCodeCacheBuffer(Isolate* isolate);
4648
}
4749

4850
extern "C" {
@@ -151,9 +153,18 @@ static int RunNodeInstance(MultiIsolatePlatform* platform,
151153
return 1; // There has been a JS exception.
152154
}
153155
assert(loadenv_ret->IsFunction());
154-
Local<Value> source = boxednode::GetBoxednodeMainScriptSource(isolate);
155-
if (loadenv_ret.As<Function>()->Call(context, Null(isolate), 1, &source).IsEmpty())
156+
Local<Value> trampoline_args[] = {
157+
boxednode::GetBoxednodeMainScriptSource(isolate),
158+
String::NewFromUtf8Literal(isolate, BOXEDNODE_CODE_CACHE_MODE),
159+
boxednode::GetBoxednodeCodeCacheBuffer(isolate),
160+
};
161+
if (loadenv_ret.As<Function>()->Call(
162+
context,
163+
Null(isolate),
164+
sizeof(trampoline_args) / sizeof(trampoline_args[0]),
165+
trampoline_args).IsEmpty()) {
156166
return 1; // JS exception.
167+
}
157168

158169
{
159170
// SealHandleScope protects against handle leaks from callbacks.

src/helpers.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import childProcess from 'child_process';
55
import { promisify } from 'util';
66
import tar from 'tar';
77
import stream from 'stream';
8+
import zlib from 'zlib';
89
import { once } from 'events';
910

1011
export const pipeline = promisify(stream.pipeline);
@@ -101,3 +102,51 @@ export function createCppJsStringDefinition (fnName: string, source: string): st
101102
}
102103
`;
103104
}
105+
106+
export async function createCompressedBlobDefinition (fnName: string, source: Uint8Array): Promise<string> {
107+
const compressed = await promisify(zlib.brotliCompress)(source, {
108+
params: {
109+
[zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
110+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: source.length
111+
}
112+
});
113+
return `
114+
static const uint8_t ${fnName}_source_[] = {
115+
${Uint8Array.prototype.toString.call(compressed)}
116+
};
117+
118+
std::string ${fnName}() {
119+
${source.length === 0 ? 'return {};' : `
120+
size_t decoded_size = ${source.length};
121+
std::string dst(decoded_size, 0);
122+
const auto result = BrotliDecoderDecompress(
123+
${compressed.length},
124+
${fnName}_source_,
125+
&decoded_size,
126+
reinterpret_cast<uint8_t*>(&dst[0]));
127+
assert(result == BROTLI_DECODER_RESULT_SUCCESS);
128+
assert(decoded_size == ${source.length});
129+
return dst;`}
130+
}
131+
132+
std::shared_ptr<v8::BackingStore> ${fnName}BackingStore() {
133+
std::string* str = new std::string(std::move(${fnName}()));
134+
return v8::SharedArrayBuffer::NewBackingStore(
135+
&str->front(),
136+
str->size(),
137+
[](void*, size_t, void* deleter_data) {
138+
delete static_cast<std::string*>(deleter_data);
139+
},
140+
static_cast<void*>(str));
141+
}
142+
143+
v8::Local<v8::Uint8Array> ${fnName}Buffer(v8::Isolate* isolate) {
144+
${source.length === 0 ? `
145+
auto array_buffer = v8::SharedArrayBuffer::New(isolate, 0);
146+
` : `
147+
auto array_buffer = v8::SharedArrayBuffer::New(isolate, ${fnName}BackingStore());
148+
`}
149+
return v8::Uint8Array::New(array_buffer, 0, array_buffer->ByteLength());
150+
}
151+
`;
152+
}

src/index.ts

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { promisify } from 'util';
1111
import { promises as fs, createReadStream, createWriteStream } from 'fs';
1212
import { AddonConfig, loadGYPConfig, storeGYPConfig, modifyAddonGyp } from './native-addons';
1313
import { ExecutableMetadata, generateRCFile } from './executable-metadata';
14-
import { spawnBuildCommand, ProcessEnv, pipeline, createCppJsStringDefinition } from './helpers';
14+
import { spawnBuildCommand, ProcessEnv, pipeline, createCppJsStringDefinition, createCompressedBlobDefinition } from './helpers';
1515
import { Readable } from 'stream';
1616
import nv from '@pkgjs/nv';
1717
import { fileURLToPath, URL } from 'url';
18+
import { execFile } from 'child_process';
1819

1920
// Download and unpack a tarball containing the code for a specific Node.js version.
2021
async function getNodeSourceForVersion (range: string, dir: string, logger: Logger, retries = 2): Promise<string> {
@@ -26,7 +27,8 @@ async function getNodeSourceForVersion (range: string, dir: string, logger: Logg
2627
} catch { /* not a valid URL */ }
2728

2829
if (inputIsFileUrl) {
29-
logger.stepStarting(`Extracting tarball from ${range}`);
30+
logger.stepStarting(`Extracting tarball from ${range} to ${dir}`);
31+
await fs.mkdir(dir, { recursive: true });
3032
await pipeline(
3133
createReadStream(fileURLToPath(range)),
3234
zlib.createGunzip(),
@@ -179,9 +181,9 @@ async function compileNode (
179181
const nodeVersion = await getNodeVersionFromSourceDirectory(sourcePath);
180182
if (nodeVersion[0] > 19 || (nodeVersion[0] === 19 && nodeVersion[1] >= 4)) {
181183
if (process.platform !== 'win32') {
182-
buildArgs.unshift('--disable-shared-readonly-heap');
184+
buildArgs = ['--disable-shared-readonly-heap', ...buildArgs];
183185
} else {
184-
buildArgs.unshift('no-shared-roheap');
186+
buildArgs = ['no-shared-roheap', ...buildArgs];
185187
}
186188
}
187189

@@ -201,6 +203,13 @@ async function compileNode (
201203

202204
return path.join(sourcePath, 'out', 'Release', 'node');
203205
} else {
206+
// On Windows, running vcbuild multiple times may result in errors
207+
// when the source data changes in between runs.
208+
await fs.rm(path.join(sourcePath, 'out', 'Release'), {
209+
recursive: true,
210+
force: true
211+
});
212+
204213
// These defaults got things to work locally. We only include them if no
205214
// conflicting arguments have been passed manually.
206215
const vcbuildArgs: string[] = [...buildArgs, ...makeArgs, 'projgen'];
@@ -230,6 +239,7 @@ type CompilationOptions = {
230239
addons?: AddonConfig[],
231240
enableBindingsPatch?: boolean,
232241
useLegacyDefaultUvLoop?: boolean;
242+
useCodeCache?: boolean,
233243
executableMetadata?: ExecutableMetadata,
234244
preCompileHook?: (nodeSourceTree: string, options: CompilationOptions) => void | Promise<void>
235245
}
@@ -258,12 +268,12 @@ async function compileJSFileAsBinaryImpl (options: CompilationOptions, logger: L
258268
const enableBindingsPatch = options.enableBindingsPatch ?? options.addons?.length > 0;
259269

260270
const jsMainSource = await fs.readFile(options.sourceFile, 'utf8');
271+
const registerFunctions: string[] = [];
261272

262273
// We use the official embedder API for stability, which is available in all
263274
// supported versions of Node.js.
264275
{
265276
const extraGypDependencies: string[] = [];
266-
const registerFunctions: string[] = [];
267277
for (const addon of (options.addons || [])) {
268278
const addonResult = await modifyAddonGyp(
269279
addon, nodeSourcePath, options.env || process.env, logger);
@@ -290,23 +300,6 @@ async function compileJSFileAsBinaryImpl (options: CompilationOptions, logger: L
290300
await fs.writeFile(path.join(nodeSourcePath, 'src', header), source);
291301
}
292302
logger.stepCompleted();
293-
294-
logger.stepStarting('Handling main file source');
295-
let mainSource = await fs.readFile(
296-
path.join(__dirname, '..', 'resources', 'main-template.cc'), 'utf8');
297-
mainSource = mainSource.replace(/\bREPLACE_WITH_ENTRY_POINT\b/g,
298-
JSON.stringify(JSON.stringify(`${namespace}/${namespace}`)));
299-
mainSource = mainSource.replace(/\bREPLACE_DECLARE_LINKED_MODULES\b/g,
300-
registerFunctions.map((fn) => `void ${fn}(const void**,const void**);\n`).join(''));
301-
mainSource = mainSource.replace(/\bREPLACE_DEFINE_LINKED_MODULES\b/g,
302-
registerFunctions.map((fn) => `${fn},`).join(''));
303-
mainSource = mainSource.replace(/\bREPLACE_WITH_MAIN_SCRIPT_SOURCE_GETTER\b/g,
304-
createCppJsStringDefinition('GetBoxednodeMainScriptSource', jsMainSource));
305-
if (options.useLegacyDefaultUvLoop) {
306-
mainSource = `#define BOXEDNODE_USE_DEFAULT_UV_LOOP 1\n${mainSource}`;
307-
}
308-
await fs.writeFile(path.join(nodeSourcePath, 'src', 'node_main.cc'), mainSource);
309-
logger.stepCompleted();
310303
}
311304

312305
logger.stepStarting('Inserting custom code into Node.js source');
@@ -338,13 +331,61 @@ async function compileJSFileAsBinaryImpl (options: CompilationOptions, logger: L
338331
logger.stepCompleted();
339332
}
340333

341-
const binaryPath = await compileNode(
342-
nodeSourcePath,
343-
extraJSSourceFiles,
344-
options.configureArgs,
345-
options.makeArgs,
346-
options.env || process.env,
347-
logger);
334+
async function writeMainFileAndCompile ({ codeCacheBlob, codeCacheMode }: {
335+
codeCacheBlob: Uint8Array,
336+
codeCacheMode: 'ignore' | 'generate' | 'consume'
337+
}): Promise<string> {
338+
logger.stepStarting('Handling main file source');
339+
let mainSource = await fs.readFile(
340+
path.join(__dirname, '..', 'resources', 'main-template.cc'), 'utf8');
341+
mainSource = mainSource.replace(/\bREPLACE_WITH_ENTRY_POINT\b/g,
342+
JSON.stringify(JSON.stringify(`${namespace}/${namespace}`)));
343+
mainSource = mainSource.replace(/\bREPLACE_DECLARE_LINKED_MODULES\b/g,
344+
registerFunctions.map((fn) => `void ${fn}(const void**,const void**);\n`).join(''));
345+
mainSource = mainSource.replace(/\bREPLACE_DEFINE_LINKED_MODULES\b/g,
346+
registerFunctions.map((fn) => `${fn},`).join(''));
347+
mainSource = mainSource.replace(/\bREPLACE_WITH_MAIN_SCRIPT_SOURCE_GETTER\b/g,
348+
createCppJsStringDefinition('GetBoxednodeMainScriptSource', jsMainSource) + '\n' +
349+
await createCompressedBlobDefinition('GetBoxednodeCodeCache', codeCacheBlob));
350+
mainSource = mainSource.replace(/\bBOXEDNODE_CODE_CACHE_MODE\b/g,
351+
JSON.stringify(codeCacheMode));
352+
if (options.useLegacyDefaultUvLoop) {
353+
mainSource = `#define BOXEDNODE_USE_DEFAULT_UV_LOOP 1\n${mainSource}`;
354+
}
355+
await fs.writeFile(path.join(nodeSourcePath, 'src', 'node_main.cc'), mainSource);
356+
logger.stepCompleted();
357+
358+
return await compileNode(
359+
nodeSourcePath,
360+
extraJSSourceFiles,
361+
options.configureArgs,
362+
options.makeArgs,
363+
options.env || process.env,
364+
logger);
365+
}
366+
367+
let binaryPath: string;
368+
if (!options.useCodeCache) {
369+
binaryPath = await writeMainFileAndCompile({
370+
codeCacheBlob: new Uint8Array(0),
371+
codeCacheMode: 'ignore'
372+
});
373+
} else {
374+
binaryPath = await writeMainFileAndCompile({
375+
codeCacheBlob: new Uint8Array(0),
376+
codeCacheMode: 'generate'
377+
});
378+
logger.stepStarting('Running code cache generation');
379+
const codeCacheResult = await promisify(execFile)(binaryPath, { encoding: 'buffer' });
380+
if (codeCacheResult.stdout.length === 0) {
381+
throw new Error('Empty code cache result');
382+
}
383+
logger.stepCompleted();
384+
binaryPath = await writeMainFileAndCompile({
385+
codeCacheBlob: codeCacheResult.stdout,
386+
codeCacheMode: 'consume'
387+
});
388+
}
348389

349390
logger.stepStarting(`Moving resulting binary to ${options.targetFile}`);
350391
await fs.mkdir(path.dirname(options.targetFile), { recursive: true });

0 commit comments

Comments
 (0)