Prior Art¶
V8 Startup Snapshots¶
V8 (Chrome/Node.js) solved the same problem for JavaScript. A V8 snapshot captures the initialized heap — built-in objects, compiled bytecode, prototype chains — into a binary blob that ships with the engine. At startup, V8 mmaps the snapshot and the heap is ready immediately, skipping thousands of object allocations.
V8 snapshots are tightly coupled to the engine version. A snapshot from V8 v12.0 cannot be loaded by V8 v12.1. There is no migration path — the snapshot must be regenerated when the engine is updated.
V8 snapshots only capture built-in state. User code cannot create custom snapshots (outside of embedder APIs like v8::SnapshotCreator which are complex and limited).
Emacs Portable Dumper (pdump)¶
Emacs faced the same problem: loading hundreds of Lisp files at startup took seconds. The original unexec approach literally dumped the process memory to disk and reloaded it — a blunt but effective hack that broke with modern security features (ASLR, W^X, PIE).
The portable dumper (Emacs 27+) replaced unexec with a structured dump. The design is particularly relevant to ghc-fastboot:
-
Object-level serialization: pdump walks the Lisp heap object-by-object, recording type and layout for each. Like ghc-fastboot's info table-driven closure walker, pdump uses Emacs's own type tags to determine object structure.
-
Relocation tables: each pointer in the dump gets a relocation entry classifying it (Lisp object, C symbol, emacs_value). At restore, pointers are fixed up based on the load address — the same three-level relocation strategy (internal, code, static) that ghc-fastboot uses.
-
mmap-based restore: the dump file is mmap'd directly, then relocations are applied in-place. pdump achieves ~3x startup speedup (from ~1.5s to ~0.5s for a typical Emacs config).
-
Limitations: pdump cannot handle arbitrary C state (window system handles, file descriptors, network connections). It captures Lisp-level state only, requiring "portable" objects that don't embed machine-specific addresses. This mirrors ghc-fastboot's constraint: freeze data and thunks, not runtime state (TSOs, MVars, IO handles).
The key difference: pdump is a build-time tool run once during Emacs compilation. ghc-fastboot's frozen is a runtime API that any Haskell program can use — freeze happens on first run, transparently.
GraalVM Native Image¶
GraalVM's native-image performs ahead-of-time compilation and captures the entire Java heap at build time. The "image heap" is embedded in the binary and mapped at startup. Points-to analysis determines which objects are reachable; unreachable objects are discarded.
This is the most sophisticated prior art: it handles objects, class metadata, reflection data, and even JIT-compiled code. However, it requires a closed-world assumption — all classes must be known at build time, which breaks dynamic class loading, a core Java feature.
CRaC (Coordinated Restore at Checkpoint)¶
OpenJDK's CRaC project checkpoints a running JVM process and restores it later. Unlike Native Image (build-time capture), CRaC captures at runtime after warmup — including JIT-compiled code, thread state, and network connections.
CRaC must coordinate resource cleanup before checkpoint (close file descriptors, disconnect sockets) and re-acquisition after restore. This "coordination" is the hard part — it's a distributed systems problem within a single process.
BEAM / Erlang¶
BEAM takes a different approach entirely: hot code swapping. Rather than snapshot/restore, BEAM allows loading new module versions at runtime. Old and new versions coexist — processes on the old version continue running, new processes use the new version. Eventually the old version is garbage collected.
This is possible because BEAM processes are isolated (no shared mutable state) and code is organized into modules with clear boundaries. BEAM also has "persistent terms" — ETS-like storage that survives code swaps, stored outside the per-process heap.
Rust / C / Go: No Runtime, No Problem¶
Languages without managed heaps sidestep the problem. Static data is embedded in the binary's .data/.rodata sections and mapped by the ELF loader at zero cost. Rust's lazy_static / once_cell patterns defer initialization to first use, and the cost is a single pointer check per access.
Go uses init() functions that run at startup, but without GC-managed heap serialization. Go's recently proposed "link-time initialization" would evaluate init() at build time and embed results — converging toward the same idea as ghc-fastboot.
Comparison¶
| System | Captures | When | Migration | User-Facing API |
|---|---|---|---|---|
| V8 snapshots | Built-in heap | Build time | None (version-locked) | Embedder only |
| Emacs pdump | Lisp heap | Build time | Relocation tables | None (transparent) |
| GraalVM Native Image | Full Java heap | Build time | None (closed world) | Annotations |
| CRaC | Full JVM process | Runtime | Resource coordination | Lifecycle callbacks |
| BEAM | Module code | Runtime | Hot swap | Module API |
| ghc-fastboot | Any closure graph | First run | Relocation + dlsym | frozen / unsafeFrozen |