Skip to content

Snapshot Format

Design Principles

  1. Zero-cost common case: same binary, fixed VA → zero relocations. The snapshot's raw bytes are directly usable as heap objects without any fixup.

  2. Graceful degradation: different binary or different VA → relocation table handles it. Different binary but compatible → symbol table + dlsym. Incompatible binary → Merkle fingerprints detect it before corruption.

  3. Packed layout: closures are copied back-to-back during freeze, regardless of their original heap layout. The snapshot has better locality than the live heap — a compacting serialization pass.

  4. Page-aligned closure data: the closure data region starts at a page boundary within the snapshot, enabling direct mmap without copying. The header and tables are in the preceding pages.

Binary Layout

┌─────────────────────────────────┐  offset 0
 SnapshotHeaderV2 (128 bytes)    
├─────────────────────────────────┤  reloc_table_offset
 RelocationEntry[] (8 bytes ea)  
├─────────────────────────────────┤  symbol_table_offset
 SymbolEntry[] (variable)        
├─────────────────────────────────┤  fingerprint_table_offset
 FingerprintEntry[] (40 bytes ea)
├─────────────────────────────────┤  closure_data_offset (PAGE-ALIGNED)
 Closure data (packed closures)  
  root closure at offset 0       
  all pointers absolute          
└─────────────────────────────────┘

Key fields:

  • target_va: fixed virtual address for zero-relocation thaw
  • snap_text_base: .text base at freeze time (for code pointer delta)
  • binary_hash: SHA-256 of .text + .rodata (same hash → skip symbol resolution)

Relocation Hierarchy

The format supports three levels of relocation, checked in order:

Level 0 — Zero relocation (same binary + fixed VA): All pointers in the closure data are absolute addresses pre-baked during freeze. Internal pointers use target_va + offset. Code/static pointers use the actual addresses from the freezing binary. If the thawing binary is the same and the VA is available, every pointer is already correct.

Level 1 — Delta relocation (same binary, different VA or ASLR shift): A single va_delta for internal pointers and a single code_delta for code pointers. Each relocation entry gets += delta. O(n) in the number of relocations but constant-factor fast (one add per pointer).

Level 2 — Symbol resolution (different binary): Code/static pointers resolved via dlsym(RTLD_DEFAULT, symbol_name). The symbol table stores Z-encoded GHC symbol names. Merkle fingerprints verify structural compatibility before applying.

The RelocationEntry Format

typedef struct {
    uint32_t offset;       // byte offset within closure data
    uint16_t type;         // RELOC_INTERNAL, RELOC_CODE_PTR, RELOC_STATIC
    uint16_t symbol_idx;   // index into symbol table (0xFFFF = none)
} RelocationEntry;         // 8 bytes

8 bytes per relocation is compact but sufficient. uint32_t offset limits closure data to 4GB — adequate for all practical use cases. uint16_t symbol_idx limits unique symbols to 65534 — a typical snapshot has hundreds, not thousands.

Relocation Strategy

Scenario Internal ptrs Code ptrs Cost
Same binary, fixed VA skip skip 0
Same binary, different VA += va_delta skip O(n)
Different binary += va_delta dlsym per symbol O(n + symbols)

Merkle Fingerprints

For cross-binary migration safety, each external symbol gets a recursive Merkle fingerprint:

typedef struct {
    uint16_t symbol_idx;
    uint16_t _pad;
    uint64_t ghc_fingerprint;   // GHC's ABI hash (diagnostic only)
    uint8_t  bare_hash[16];     // hash of own bytes, pointers zeroed
    uint8_t  merkle_hash[16];   // hash including transitive dependencies
} FingerprintEntry;             // 40 bytes

The bare hash captures the closure's own layout (field types, sizes, padding). The Merkle hash captures the transitive structure — if any dependency changed, the hash changes. Cycles are handled by using the bare hash as a provisional value for back-edges.

This provides structural type checking at the binary level: two closures that are "the same type" in different binaries will have matching Merkle hashes if and only if their layouts are actually compatible.

Packed Back-to-Back Layout

During freeze, the closure walker copies each discovered closure into a contiguous buffer with no gaps:

size_t offset = s->buffer_used;
memcpy(s->buffer + offset, closure, size);
s->buffer_used += size;

This produces a snapshot with better locality than the original heap — closures that are logically related (part of the same data structure) end up on adjacent pages, even if they were scattered across different GC generations on the live heap. The snapshot is a compacting copy, like a compact region but supporting all closure types.