Compiled executables: alias embedded bytecode instead of copying it; smaller, page-friendly bytecode (WebKit#494)
What does this PR do?
bun build --compile --bytecode executables get a smaller, faster bytecode section that JSC reads in place instead of copying.
Bun side:
ZigSourceProvidermarks standalone-executable bytecode persistent so decoded instruction streams and expression info alias the mmapped section instead of heap copies.- One
EncoderStringTableshared by every chunk and every embedded internal module: each ≥4-char non-symbol string is a 4-byte ordinal in every payload; the characters are written once as a graph section (Flags::HAS_BYTECODE_STRING_TABLE).DecoderStringTableonJSVMClientDatareads it with a demand-zero atom slot per ordinal. Flags::HAS_SOURCE_HASHES: each module'sSourceCodeKeyhash is baked in so a bytecode launch never pages in source text just to hash it.- The executable also carries ahead-of-time bytecode for the internal modules (
node:*,bun:*,internal:*) the bundle imports and everything they eagerlyrequire();InternalModuleRegistrydecodes instead of parsing. - Chunk names inside an executable are numbered (
./_12.js) instead ofchunk-<hash>.js.
WebKit side (oven-sh/WebKit c148a12dd82b):
- Decoded instruction streams and expression info borrow a persistent payload (oven-sh/WebKit#494).
- Bytecode format: 32-bit offsets, natural alignment, sparse metadata offset table, per-record stored hashes, arrays-first records, string dedup, ≤3-char strings inline in their slot (oven-sh/WebKit#497, #499, #502).
- Numbered strings with stored hashes so repeat lookups skip re-hashing (oven-sh/WebKit#506); the 2-char atom table lives on
VM, not perDecoder. EncoderStringTable/DecoderStringTableandexternalStringTag;encodeBuiltinFunctiontakes the same table.- Per-region checksums skipped for persistent payloads (they guard on-disk cache files, not the executable's own section).
Measured
57 npm packages (typescript, three, echarts, mathjs, tfjs, aws-sdk, firebase, antd, mui, prettier, babel, …) bundled with --minify to 36 MB, --compile --bytecode, importing 38 of them and running every module initializer. Medians; footprint = Bun.unsafe.memoryFootprint().
macOS arm64, 1.4.0 vs this PR:
--bytecode | build | payload | startup | footprint |
|---|---|---|---|---|
| cjs 1.4.0 | 16.7 s | 295 MB | 224 ms | 223 MB |
| cjs this PR | 2.4 s | 168 MB | 184 ms | 203 MB |
| esm 1.4.0 | 17.0 s | 295 MB | 215 ms | 236 MB |
| esm this PR | 2.4 s | 168 MB | 181 ms | 216 MB |
| esm --splitting 1.4.0 | 2.7 s | 283 MB | 176 ms | 197 MB |
| esm --splitting this PR | 2.3 s | 164 MB | 149 ms | 177 MB |
Linux x64, 1.4.0 vs this PR:
--bytecode | build | payload | startup | footprint |
|---|---|---|---|---|
| cjs 1.4.0 | 157 s | 366 MB | 620 ms | 528 MB |
| cjs this PR | 7.5 s | 211 MB | 489 ms | 304 MB |
| esm 1.4.0 | 158 s | 366 MB | 604 ms | 535 MB |
| esm this PR | 7.7 s | 211 MB | 478 ms | 332 MB |
| esm --splitting 1.4.0 | 13.5 s | 351 MB | 458 ms | 383 MB |
| esm --splitting this PR | 6.6 s | 207 MB | 382 ms | 260 MB |
macOS phys_footprint counts anonymous memory only; the file-backed win is separate — vmmap shows the mmapped __BUN section resident at 129 MB on main vs 41 MB on this PR (0 dirty in both).
Where the startup time went
samply main-thread profile of the esm+split executable, main vs this PR (absolute samples at 8 kHz):
| main | this PR | |
|---|---|---|
unlinkedCodeBlockFor (decode + fallback parse) | 394 | 208 |
| identifier atomization (hash + compare + add) | 103 | 33 |
CachedVector<CachedFunctionExecutable> | 55 | 0 |
Lexer::lex (parse fallback) | 45 | 0 |
CachedPtr<CachedInstructionStream> (copy) | 18 | 0 |
regionChecksumMatches | 0 | 0¹ |
VarintReader::u32 | 0 | 25 |
¹ skipped for persistent payloads as of this PR; before the skip it was 21.
How did you verify your code works?
test/bundler/bundler_compile.test.ts,test/bundler/bundler_compile_splitting.test.ts— every--compile --bytecodematrix cell.- New: a
--compile --bytecode --splittingexecutable with 4,000 functions reports itsAnonymous:fromsmaps_rollup; the same binary withBUN_JSC_useBorrowedBytecodeFromCache=0is the control and must be several MB higher. - WebKit:
JSTests/stressthrough the disk cache with the persistent-payload switch on and off, unchanged vs main.