SageLang Garbage Collector Guide

June 14, 2026 · View on GitHub

Overview

SageLang uses a concurrent tri-color mark-sweep garbage collector with SATB (Snapshot-At-The-Beginning) write barriers, designed for sub-millisecond stop-the-world (STW) pauses.

Design Goals

  • Sub-millisecond STW pauses: Only two brief STW phases (root scan ~50-200us, remark ~20-50us)
  • Concurrent marking: The mark phase runs alongside the mutator (application code)
  • Concurrent sweeping: Dead objects are freed in small batches without stopping the mutator
  • Thread-safe: All GC operations are mutex-protected for multi-threaded Sage programs
  • Works across all backends: C interpreter, LLVM compiled, bytecode VM, and self-hosted

Collection Phases

Phase 1: ROOT SCAN (STW, ~50-200us)
    |  Snapshot all roots (environments, VM stack, modules)
    |  Shade root objects GRAY, enable write barrier
    v
Phase 2: CONCURRENT MARK (no STW)
    |  Process gray objects from mark stack
    |  Shade children gray, turn parent black
    |  Mutator runs freely; write barrier catches overwrites
    v
Phase 3: REMARK (STW, ~20-50us)
    |  Re-scan roots for new references
    |  Drain barrier-shaded objects
    |  Disable write barrier
    v
Phase 4: CONCURRENT SWEEP (no STW)
    |  Free WHITE objects in small batches (256 per step)
    |  Reset surviving objects to WHITE for next cycle
    v
IDLE (wait for next trigger)

Tri-Color Invariant

Every object has one of three colors:

ColorMeaningState
WHITENot yet reached by markerCandidate for collection
GRAYReachable, but children not yet scannedOn the mark stack
BLACKReachable, all children scannedWill survive this cycle

The key invariant: no BLACK object points directly to a WHITE object. The SATB write barrier maintains this invariant during concurrent marking.

SATB Write Barrier

When the mutator overwrites a reference field during concurrent marking, the old value is shaded gray before the write occurs:

// Before: slot contains old_value (may be white)
// After:  slot contains new_value
GC_WRITE_BARRIER(old_value);  // shade old_value gray if white
slot = new_value;

This ensures the old value remains reachable for the current GC cycle, preventing premature collection. The trade-off is slight over-retention (floating garbage survives one extra cycle), which is acceptable for latency-sensitive applications.

Write Barrier Insertion Points

LocationOperationBarrier
env_define()Update existing variableGC_WRITE_BARRIER(old_value)
env_assign()Assign up scope chainGC_WRITE_BARRIER(old_value)
array_set()Overwrite array elementGC_WRITE_BARRIER(old_element)
dict_set()Update existing dict entryGC_WRITE_BARRIER(old_value)
instance_set_field()Set instance propertyVia dict_set barrier

Allocated-Black Invariant

Objects allocated during concurrent marking are born BLACK. This means they automatically survive the current GC cycle without needing to be traced. This avoids the need to track new allocations specially during marking.

GC Triggering

Collection is triggered automatically when either threshold is exceeded:

  • Object count: gc.object_count >= gc.next_gc_objects
  • Byte count: gc_live_bytes() >= gc.next_gc_bytes

Thresholds adapt after each collection based on reclamation ratio:

  • Low reclamation (<12.5%): shrink padding (collect sooner)
  • High reclamation (>100% of live): grow padding (collect less often)

API Reference

Control Functions

gc_collect()    # Trigger a full collection cycle
gc_enable()     # Enable automatic GC
gc_disable()    # Disable automatic GC (manual collect still works)

Statistics

let stats = gc_stats()
print stats["collections"]       # Number of GC cycles run
print stats["num_objects"]        # Current live object count
print stats["current_bytes"]      # Current managed memory in bytes
print stats["objects_freed"]      # Objects freed in last cycle

Pinning

Pinning suppresses auto-collection during multi-step allocations where intermediate state may not be rooted:

gc_pin();
// ... allocate multiple objects that reference each other ...
gc_unpin();

Debug Mode

gc_enable_debug();   // Print phase timing to stderr
gc_disable_debug();

With debug enabled, each collection prints:

[GC] Collection #42: root=150us mark=2300us remark=35us sweep=800us total=3285us freed=1247

Performance Characteristics

MetricTargetTypical
Root scan STW< 500us50-200us
Remark STW< 200us20-50us
Total STW per cycle< 1ms70-250us
Mark throughputN/A (concurrent)~500K objects/sec
Sweep throughputN/A (concurrent)~1M objects/sec
Write barrier overhead< 5%~2-3%

Implementation Files

FileRole
include/gc.hGCHeader, GC struct, mark stack, write barrier macros, phase constants
src/c/gc.cCore implementation: phases, marking, sweeping, barriers, timing
src/c/env.cWrite barriers on env_define and env_assign
src/c/value.cWrite barriers on array_set and dict_set
src/sage/gc.sageSelf-hosted GC interface (phase constants, stats formatting)

GC Modes

Sage supports three garbage collection modes, selectable at startup or runtime:

ModeFlagAlgorithmBest For
Tracing--gc:tracing (default)Concurrent tri-color mark-sweepThroughput, large heaps
ARC--gc:arcReference counting + simple cycle checkDeterministic cleanup, real-time
ORC--gc:orcReference counting + trial deletion cyclesComplex object graphs, balanced

Selecting a GC Mode

sage file.sage                    # Default: tracing GC
sage --gc:arc file.sage           # ARC mode
sage --gc:orc file.sage           # ORC mode (recommended for complex programs)
sage --gc:tracing file.sage       # Explicit tracing mode

Or at runtime:

gc_set_arc()       # Switch to ARC mode
gc_set_orc()       # Switch to ORC mode
print gc_mode()    # Returns "tracing", "arc", or "orc"

ORC Mode (Optimized Reference Counting)

ORC is inspired by Nim's ORC garbage collector. It combines ARC's deterministic reference counting with a proper trial deletion cycle collector based on Lins' algorithm.

How ORC Works

ORC shares ARC's reference counting base: objects are freed immediately when their reference count drops to zero. The key difference is cycle detection.

When a reference count is decremented but not to zero, ORC marks the object as a PURPLE candidate — it might be part of a reference cycle. Periodically, ORC runs a three-phase trial deletion algorithm:

Phase 1: MARK ROOTS
    |  Collect all PURPLE objects as candidate cycle roots
    v
Phase 2: SCAN (Trial Deletion)
    |  For each candidate, trial-decrement ref counts of all reachable objects
    |  If trial count reaches 0 → mark WHITE (confirmed cycle garbage)
    |  If trial count > 0 → mark BLACK (has external refs, restore counts)
    v
Phase 3: COLLECT
    |  Free all WHITE objects (confirmed unreachable cycles)
    v
IDLE (wait for next trigger)

ORC Colors (Lins' Algorithm)

ColorMeaning
BLACKNormal: in use, not a candidate
PURPLEPossible cycle root (ref count decremented but > 0)
GRAYBeing scanned (trial decrement in progress)
WHITEConfirmed garbage (part of an unreachable cycle)

When to Use ORC

  • Programs with complex object graphs (linked lists, trees, graphs)
  • Programs that create reference cycles (e.g., parent/child back-references)
  • When you want deterministic cleanup (like ARC) but need robust cycle handling
  • When tracing GC pauses are unacceptable but ARC's simple cycle check isn't sufficient

ORC vs ARC

FeatureARCORC
Reference countingYesYes
Deterministic free on refcount=0YesYes
Cycle detection algorithmSimple buffer checkLins' trial deletion
Can detect complex cyclesLimitedYes
Collection triggerEvery 1000 decrementsEvery 500 decrements
OverheadLowerSlightly higher (candidate tracking)

ORC Statistics

gc_set_orc()
# ... run program ...
let stats = gc_stats()
print stats["collections"]    # Total collection cycles (including ORC)

Comparison with Previous GC

FeatureOld GCNew GC
AlgorithmStop-the-world mark-sweepConcurrent tri-color mark-sweep
STW pauseEntire mark + sweepRoot scan + remark only
Write barrierNoneSATB (snapshot-at-the-beginning)
New object colorUnmarkedBorn BLACK during marking
Mark trackingSingle marked bitTri-color (WHITE/GRAY/BLACK)
Mark processingRecursiveExplicit mark stack
Sweep modeFull passIncremental (256 objects/step)
Pause timingNot trackedNanosecond-precision per phase
Thread safetyGlobal mutexGlobal mutex + barrier flag