Compiled executables: alias embedded bytecode instead of copying it; smaller, page-friendly bytecode (WebKit#494)

Merged
JA
3mo ago
+989-141

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:

  • ZigSourceProvider marks standalone-executable bytecode persistent so decoded instruction streams and expression info alias the mmapped section instead of heap copies.
  • One EncoderStringTable shared 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). DecoderStringTable on JSVMClientData reads it with a demand-zero atom slot per ordinal.
  • Flags::HAS_SOURCE_HASHES: each module's SourceCodeKey hash 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 eagerly require(); InternalModuleRegistry decodes instead of parsing.
  • Chunk names inside an executable are numbered (./_12.js) instead of chunk-<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 per Decoder.
  • EncoderStringTable / DecoderStringTable and externalStringTag; encodeBuiltinFunction takes 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:

--bytecodebuildpayloadstartupfootprint
cjs 1.4.016.7 s295 MB224 ms223 MB
cjs this PR2.4 s168 MB184 ms203 MB
esm 1.4.017.0 s295 MB215 ms236 MB
esm this PR2.4 s168 MB181 ms216 MB
esm --splitting 1.4.02.7 s283 MB176 ms197 MB
esm --splitting this PR2.3 s164 MB149 ms177 MB

Linux x64, 1.4.0 vs this PR:

--bytecodebuildpayloadstartupfootprint
cjs 1.4.0157 s366 MB620 ms528 MB
cjs this PR7.5 s211 MB489 ms304 MB
esm 1.4.0158 s366 MB604 ms535 MB
esm this PR7.7 s211 MB478 ms332 MB
esm --splitting 1.4.013.5 s351 MB458 ms383 MB
esm --splitting this PR6.6 s207 MB382 ms260 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):

mainthis PR
unlinkedCodeBlockFor (decode + fallback parse)394208
identifier atomization (hash + compare + add)10333
CachedVector<CachedFunctionExecutable>550
Lexer::lex (parse fallback)450
CachedPtr<CachedInstructionStream> (copy)180
regionChecksumMatches00¹
VarintReader::u32025

¹ 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 --bytecode matrix cell.
  • New: a --compile --bytecode --splitting executable with 4,000 functions reports its Anonymous: from smaps_rollup; the same binary with BUN_JSC_useBorrowedBytecodeFromCache=0 is the control and must be several MB higher.
  • WebKit: JSTests/stress through the disk cache with the persistent-payload switch on and off, unchanged vs main.