JSC/WTF: startup snapshot support (immortal snapshot heap, restore hooks, deterministic placement)
JavaScriptCore side of startup snapshots in Bun: a process is frozen once it is idle, and later launches of the same executable map the frozen heap and continue in the event loop instead of booting. Four commits on top of the current pin, each building on its own; 54 files, +998/−158. Companion: oven-sh/mimalloc#13 (allocator side); the Bun side is oven-sh/bun#37225.
| Commit | What | Cost when no snapshot exists |
|---|---|---|
| Heap: immortal snapshot blocks; clean pages | Heap::freezeCurrentHeapAsImmortalStartupSnapshot() runs after a full collection and makes every block and precise allocation immortal: its marks become its permanent liveness and the collector never marks into, sweeps, allocates from or ages it. Barriers on snapshot cells record the cell in a side set instead of writing its state; written cells plus the precise allocations that existed at the freeze are the full-GC roots for the snapshot. Writes that ordinary use would make into snapshot cells are moved out or skipped: snapshot Structures (one spare bitfield bit) lock through a striped side lock, snapshot UnlinkedCodeBlocks are not aged, snapshot watchpoint sets chain new watchpoints through a side table, snapshot strings are made static and static strings no longer write their refcount, and a few identity writes compare first. | one byte test in areMarksStale() per mark, and isStatic() in StringImpl::ref()/deref(); everything else is behind hasImmortalBlocks() or a per-object bit. New options keep current defaults. |
| Bytecode cache: alias persistent payloads | Instruction streams and expression info decoded from a persistent (mapped or embedded) cache borrow its bytes instead of copying them; InstructionStream refs read through a span the stream owns, so owned and borrowed streams share one branch-free path. Cache-generated executables remember their record so a jettisoned code block can be decoded again (opt-in option). | none; borrowing itself is a memory saving for every executable that ships bytecode (about 20 MB on a large application, measured plain, no snapshot) |
VM::didRestoreFromStartupSnapshot() | Stack bounds, AutomaticThreads, ParkingLot entries, the RNG (and, on Linux, the /dev/urandom descriptor RandomDevice holds — the restored number was the build process's), ICU-backed Intl/date caches, regexp code, per-heap tables that grow every run, allocation pacing (the snapshot's limits were computed while its cells still counted as live), and the kernel side of signal handling — SignalHandlers::reinstallAfterSnapshotRestore() re-creates the Mach exception port/thread or sigaction entries, after which the config is frozen again. On Linux it also re-installs the thread suspend/resume signal handler (Thread::reinstallSuspendResumeSignalHandlerForStartupSnapshotRestore()), and adopting the main thread forgets StackBounds' cached original environ, which is a build-process stack address that clamped the origin below the live frames whenever the new stack happened to land over that range. On the way in, completeAllJITPlansBeforeStartupSnapshot() completes JS plans and cancels+joins wasm plans, which were otherwise still installing code into the pool while it was being written. | none |
| OSAllocator hints | Deterministic placement of reservations while a snapshot is being built or restored, keyed off the same variable the allocator uses. | one getenv per process |
Measurements, this branch vs. stock WebKit at the same commit, both built the same way (external mimalloc), 5 interleaved JetStream2 pairs on an M-series laptop: parity (+0.05 % on medians; per-benchmark spread is the usual ±5 %). Peak RSS of an allocation-churn script equal or lower in 7 of 8 pairs; hello-world +0.1 MB. JSTests/stress run differentially against the stock shell: identical pass/fail sets over the full suite (5,599 tests). Exercised further by test/js/bun/startup-snapshot on the Bun branch and by an application that starts from a snapshot.
Earlier iterations of this branch kept executables' link state and structures' transition tables out of line as well; those turned out to add loads on shared paths (and the link-state arena was never freed) without a measurable memory benefit, so they are gone. The UnlinkedMetadataTable compaction that used to be here is unrelated to snapshots and will be proposed separately.