Bytecode cache: smaller payloads that page in only what is decoded (alias mapped bytes, region layout, 32-bit offsets, sparse metadata table)
The bytecode-cache parts of the startup-snapshot branch (#397) that help every bun build --compile --bytecode executable, plus the format work that came out of measuring them, plus the UnlinkedMetadataTable change (was #496). Supersedes #187; #495/#496 folded in here. Bun side: oven-sh/bun#40201.
Test corpus for all numbers: a 12 MB minified bundle (typescript, babel, prettier, zod, …), 48k functions, encoded as one module the way Bun's --bytecode does; jsc shell, x64 Linux; "resident" = mincore on the cache file mapped cold.
| main | this PR | |
|---|---|---|
| cache size | 75.2 MB (6.4× source) | 38.9 MB (3.3×) incl. checksums |
| resident after decoding the top-level block (module load) | 32.7 MB, 143 ms | 1.3 MB, 8–10 ms |
| … + bodies of its 5,823 direct children | 50.4 MB, 280 ms | 9.7 MB, 62 ms |
| … everything (48k blocks) | 71.7 MB, 785 ms | 36.2 MB, 283 ms |
| peak RSS force-decoding all 48k (none linked) | 217 MB | 175 MB (metadata table change alone) |
anonymous memory of a Bun --compile --bytecode binary after running 4,000 functions | 43.7 MB | 32.0 MB (aliasing on vs off, same binary) |
Commits
- Decoded
InstructionStream/ExpressionInfoalias a persistent payload instead of copying it.CachePayload::setIsPersistent()is an explicit embedder promise (Bun: bytecode embedded in the running executable's section).InstructionStream::Refreads through a span the stream owns (writer refreshes it after every mutation) so owned/borrowed share one branch-free path;UnlinkedCodeBlockreports only owned bytes to the GC.useBorrowedBytecodeFromCache(default on); shell testing switchdiskCachePayloadIsPersistentForTesting. - Region layout. A block's arrays and all its children's executable records are written contiguously; bodies are queued and emitted breadth-first;
ExpressionInfogoes to a cold tail. Encoder-only (offsets are relative). This is the 32.7 MB → 1.9 MB row by itself. - 32-bit relative offsets,
alignof(T)alignment, sparse metadata offset table.CachedPtr8→4 B,CachedVector16→8 B; no more rounding every allocation to 16;CachedMetadataTablestores only the entries where the cumulative offset changes instead ofunsigned[51](212 B) in every code block. 75.2 → 47.8 MB. - Bounds-check the sparse table's indices on decode.
- Varint tails for the two per-function records; inline string characters; drop dead offset fields. Counts/registers/positions/flags of
CachedCodeBlockandCachedFunctionExecutableare a LEB128 tail after the fixed record (positions as deltas from the function start), read once into the constructed object; the fieldsCachedBytecode::commitUpdatespatches stay fixed.CachedUniquedStringImplis a 4-byte header followed by the characters.CachedString/CachedIdentifier/CachedHashMap/CachedInlineMapno longer carry an unusedVariableLengthObjectoffset (CachedIdentifierwas 12 bytes for a 4-byte pointer). 47.8 → 40.5 MB. - Store byte-identical arrays once (instruction streams, expression info, jump tables, metadata steps): content-hashed in the encoder, later copies point at the first. 40.5 → 37.9 MB.
- CRC-32C per code block / executable record / ExpressionInfo. Computed by the encoder after all deferred slots are final; checked with hardware
crc32cbefore a block is decoded (only over bytes that decode reads anyway). A damaged block decodes as absent → generated from source; damaged ExpressionInfo → empty. Pointers from slots the shell'scommitUpdatesrewrites are bounds-checked, andcommitUpdatesre-seals what it patches. Cost: +0.96 MB (2.5%), decode time and residency unchanged within noise; 244 single-byte corruptions swept across a cache file with no crashes.verifyBytecodeCacheChecksums(default on). UnlinkedMetadataTableholds only the offset table while noCodeBlockis linked; the value-profile/metadata buffer is allocated atlink()and released when the owning table is unlinked (was: allocated at finalize/decode and kept for theUnlinkedCodeBlock's life).
Testing
JSTests/stress through the disk cache (write run + JSC_forceDiskCache read run, all 5,259 tests without special mode requirements), with and without the persistent-payload switch, after each commit: identical pass/fail to main (the v8-*finalizationregistry* and a couple of stack-depth regexp tests are flaky in cache mode on main too). Bun built against the preview: bun-build-compile, bundler_compile -t bytecode, bundler_compile_splitting test files pass, plus the new RSS test in oven-sh/bun#40201.