Bytecode cache: arrays-first code block records, strings deduplicated by contents, geometric encoder pages
Follow-up to #494. Three independent changes to the bytecode cache encoder/decoder; no change to what gets decoded.
1. Arrays-first code block records with a varint layout. A code block is written as one contiguous region: its arrays (metadata steps, instruction bytes, constants, source-code representations, identifiers, jump targets, child executable slots, and an extras record only when it has rare data / directives / out-of-line jump targets), then a 16-byte record — checksum, offset-in-region, region size, ExpressionInfo slot — followed by a varint tail with every count and each array's offset relative to the region start, then the derived record's members, then the children's executable records. Offsets come out at 1–2 bytes for nearly every function. Gone: the fixed CachedVector/CachedPtr members (8/4 bytes each whether used or not), the separate CachedInstructionStream record, and the CachedMetadataTable header. ~122 → ~50 bytes per function. The integrity check verifies the tail parses within the region, every array lies inside it (or, for the four shareable ones, folds it into the CRC), then the CRC, then each child record.
2. Strings deduplicated by contents. Non-symbol strings decode to AtomStringImpl::add(characters), so two records with the same characters are interchangeable; the encoder now writes each distinct string once, keyed by contents rather than StringImpl identity — including string constants, which CachedJSValue wrote inline per use. On the bundle below 58% of string records were duplicates. (Also means one atom per distinct literal at runtime instead of one per use site.)
3. Encoder pages grow geometrically. offsetOf/bytesAt walk the page list; with fixed 4 KB pages a 100 MB payload took 68 s to encode. Now 5 s.
4. UnlinkedMetadataTable decoded from a persistent payload keeps only a pointer to the cached steps and expands them into the linked buffer at link() (back to pointer-only at unlink()), instead of allocating and filling the 104/312-byte offset table per decoded block.
Plus two jsc-shell test helpers, generateBytecodeCacheFile(source, out, "module"|"program") and bytecodeCachePageTouch(source, cache, kind, depth), which do what an embedder's ahead-of-time build and lazy decode do (used for the numbers below).
Measured on a 30.4 MB minified single-module bundle of 57 real libraries (typescript, prettier, babel, three, lodash, aws-sdk v3, firebase, antd, mui, echarts, mathjs, mongoose, eslint, svelte/compiler, tfjs, zod, …), 135k functions:
| #494 | this | |
|---|---|---|
| cache size | 105.0 MB (3.45× source) | 89.4 MB (2.94×) |
| pages touched, module load only | 14.8 MB | 13.6 MB |
| pages touched, decode every top-level function (60k) | 57.6 MB | 49.2 MB |
| decode everything (135k blocks) | 882 ms | 752 ms |
| peak RSS, decode everything (payload mapped persistent) | — | 322 MB (379 MB with useBorrowedBytecodeFromCache=0) |
| encode | 68 s | 4.9 s |
JSTests/stress through the disk cache (write then read, persistent and not): no change vs main.