The OCaml Garbage Collector

2026-10-04 ·

OCaml has two heaps and two collectors: a small minor heap collected by copying, and a large major heap collected incrementally by mark-and-sweep. Almost every tuning knob exists to change the boundary between them, or to change how much of the major heap the collector is willing to leave unreclaimed at any moment.

§ 01

Inspecting the live state

Gc.stat forces a full major collection first, so the numbers it returns are exact rather than a snapshot mid-cycle. Expensive - do not call it in a hot loop.

let () =
let s = Gc.stat () in
Printf.printf "minor_words: %.0f\n" s.minor_words;
Printf.printf "major_words: %.0f\n" s.major_words;
Printf.printf "minor_collections: %d\n" s.minor_collections;
Printf.printf "major_collections: %d\n" s.major_collections;
Printf.printf "heap_words: %d\n" s.heap_words;
Printf.printf "top_heap_words: %d\n" s.top_heap_words;
Printf.printf "compactions: %d\n" s.compactions

Gc.quick_stat skips the forced collection, so it is cheap enough to sample periodically in a running service.

let s = Gc.quick_stat ()

Run it.

$ dune exec ./main.exe

What it prints on a program that allocates and discards a few million small values.

minor_words: 42317842
major_words: 1284003
minor_collections: 5171
major_collections: 12
heap_words: 1409024
top_heap_words: 1409024
compactions: 0

Print the collector\'s full configuration - the same fields OCAMLRUNPARAM below sets.

let () = Gc.print_stat stdout
§ 02

The minor heap

Every allocation that is not immediately known to be large starts in the minor heap: a small, contiguous, fixed-size region. Allocating there is a pointer bump, not a call into an allocator. A minor collection copies every value still reachable into the major heap or an older minor generation, and discards the rest by doing nothing to it at all - dead minor objects are never individually freed, the whole region is simply reused.

A minor collection fires under three separate conditions: the minor heap fills up, Gc.minor () is called explicitly, or a major GC slice is due and OCaml drains the minor heap first so nothing partially-promoted confuses the major collector.

let () = Gc.minor ()

A microbenchmark of pure allocation pressure - nothing here should reach the major heap unless it escapes the loop, which sum does not.

let sum n =
let total = ref 0 in
for i = 1 to n do
(* boxed only because of the tuple - forces an allocation *)
let pair = (i, i * i) in
total := !total + fst pair
done;
!total
let () =
let before = Gc.minor_words () in
ignore (sum 10_000_000);
let after = Gc.minor_words () in
Printf.printf "allocated: %.0f words\n" (after -. before)

Gc.minor_words is cheap enough to call around an arbitrary region of code, which makes it the fastest way to answer whether a change actually reduced allocation.

$ dune exec ./main.exe
allocated: 30000000 words

The same computation without the tuple allocates nothing beyond the loop counter, which lives in a register.

let sum n =
let total = ref 0 in
for i = 1 to n do
total := !total + i
done;
!total

Confirming it.

$ dune exec ./main.exe
allocated: 0 words
§ 03

The major heap

Values that survive enough minor collections, or that are allocated directly at a large size, live in the major heap. It is collected incrementally: the collector does a bounded slice of marking or sweeping on every allocation, rather than stopping the program for a full pass, which is what keeps a long-lived server from pausing for the size of its whole live set at once.

Force a full major collection, including a check that nothing is unreachable from any root. Gc.compact additionally defragments the heap afterward, which is far more expensive and rarely worth it outside of trying to return memory to the OS before a long idle period.

let () = Gc.full_major ()
let () = Gc.compact ()

A closure captured in a long-lived list is exactly the kind of value that survives into the major heap and stays there.

let cache : (int * (unit -> int)) list ref = ref []
let register n =
cache := (n, fun () -> n * n) :: !cache

Watching it happen: major_words grows in step with each registration, while minor_words grows and then plateaus as the minor heap is repeatedly recycled.

let () =
for i = 1 to 100_000 do register i done;
let s = Gc.quick_stat () in
Printf.printf "major_words: %.0f\n" s.major_words
§ 04

OCAMLRUNPARAM

Set at process startup, as an environment variable. Nothing here needs a recompile.

$ OCAMLRUNPARAM="s=4M,o=200" dune exec ./main.exe

The main flags, with their defaults.

s=<words> minor heap size default 256k words (2MB on 64-bit)
o=<percent> major heap increment default 120
O=<percent> minor->major overhead default 120
l=<words> max stack size default 1024k words
v=<flags> verbosity, bitmask - see below

s controls minor heap size directly, in words. The setting that matters most for allocation-heavy code: a bigger minor heap means fewer, cheaper minor collections at the cost of worse cache locality and a larger pause per collection.

$ OCAMLRUNPARAM="s=8M" dune exec ./main.exe

Set it from inside the program instead, when you control the deployment but not the environment. Must happen before any allocation-heavy code runs, ideally at the very top of main.

let () =
Gc.set { (Gc.get ()) with Gc.minor_heap_size = 8 * 1024 * 1024 }

o is the major heap increment: how much bigger the major heap grows, as a percentage of its current size, each time it needs to grow. Lower means the collector runs more often but the heap stays smaller; higher trades memory for fewer major cycles.

$ OCAMLRUNPARAM="o=80" dune exec ./main.exe

v is a bitmask; the two bits worth turning on print a line to stderr on every minor and major collection, which is the fastest way to see collection frequency without any external tool.

$ OCAMLRUNPARAM="v=0x01" dune exec ./main.exe # major collections
$ OCAMLRUNPARAM="v=0x02" dune exec ./main.exe # minor collections
$ OCAMLRUNPARAM="v=0x400" dune exec ./main.exe # heap compaction
§ 05

Reading the verbose GC log

Run with both major and minor logging on.

$ OCAMLRUNPARAM="v=0x3" dune exec ./main.exe

A representative line from a minor collection. words is how much survived the copy - large relative to the minor heap size means most of what you allocate is actually living past a single collection, which is worth investigating.

<<< begin major GC cycle
Minor collection: 262144 words allocated, 8213 words live

A major GC slice, with the mark/sweep split. slice_work is the incremental budget being spent on this call; a program with a small minor heap and heavy allocation calls into the major slicer far more often, at smaller amounts each time.

Major slice: work = 5000, mark = 4213, sweep = 787

Pipe it through a count to get collection frequency directly, without instrumenting the program at all.

$ OCAMLRUNPARAM="v=0x2" dune exec ./main.exe 2>&1 | grep -c "Minor collection"
5171
§ 06

Allocation profiling with memtrace

Install it.

$ opam install memtrace

Two lines: start a trace at the top of main, and it writes on process exit via an at_exit handler registered internally.

let () = Memtrace.trace_if_requested ~context:"main" ()
(* the rest of the program *)

Enable it via environment variable rather than recompiling for it - the instrumentation stays compiled in and idle unless asked for.

$ MEMTRACE=trace.ctf dune exec ./main.exe

Open the trace with the interactive viewer, which shows a live-bytes-over-time graph and lets you click into any point to see the allocation callstack that put those bytes there.

$ opam install memtrace_viewer
$ memtrace-viewer trace.ctf

Or summarize from the command line, for CI, without the interactive viewer.

$ memtrace_dump trace.ctf | head -30
§ 07

A worked leak

A cache with no eviction is the canonical major-heap leak: every entry is reachable forever, so the major heap only grows, and Gc.full_major before checking never shrinks it back.

let cache : (string, string) Hashtbl.t = Hashtbl.create 16
let handle_request key =
match Hashtbl.find_opt cache key with
| Some v -> v
| None ->
let v = expensive_compute key in
Hashtbl.replace cache key v;
v

Confirm it with Gc.stat sampled periodically: major_words climbing linearly with request count, with no plateau, is the signature - a correctly bounded cache\'s major_words rises and then flattens once the cache is full.

let sample_count = ref 0
let handle_request key =
incr sample_count;
if !sample_count mod 10_000 = 0 then begin
let s = Gc.quick_stat () in
Printf.eprintf "requests=%d major_words=%.0f\n"
!sample_count s.major_words
end;
(* ... *)

The output that confirms it, run against ten of thousands of unique keys.

requests=10000 major_words=1204812
requests=20000 major_words=2401044
requests=30000 major_words=3599281
# linear, no plateau: the cache is unbounded

The fix: cap the cache size, evicting the oldest entry once full. This bounds major_words at the cache\'s capacity regardless of request count.

let capacity = 10_000
let order : string Queue.t = Queue.create ()
let handle_request key =
match Hashtbl.find_opt cache key with
| Some v -> v
| None ->
let v = expensive_compute key in
if Hashtbl.length cache >= capacity then begin
let oldest = Queue.pop order in
Hashtbl.remove cache oldest
end;
Hashtbl.replace cache key v;
Queue.push key order;
v
§ 08

Weak references and Ephemeron, as the library-level fix

A weak hash table does the eviction above automatically, keyed on whether the value is still reachable from anywhere else - once nothing else points at it, the entry disappears at the next major collection with no explicit eviction code.

module WeakCache = Ephemeron.K1.Make (struct
type t = string
let equal = String.equal
let hash = Hashtbl.hash
end)
let cache : string WeakCache.t = WeakCache.create 16

Using it looks like an ordinary hashtable; the difference is invisible at the call site and entirely in the memory behavior.

let handle_request key =
match WeakCache.find_opt cache key with
| Some v -> v
| None ->
let v = expensive_compute key in
WeakCache.replace cache key v;
v

The caveat: an ephemeron\'s data is dropped only when its key becomes unreachable, so if handle_request\'s own caller keeps every key string alive in some other structure, the cache never shrinks despite being weak - weak tables solve dangling references, not application-level retention.

(* if every key ever passed in is also stored in a request log, *)
(* the cache entries stay reachable through that log instead *)
§ 09

Large allocations and custom blocks

Anything past a size threshold (roughly 256 words by default, tied to the minor heap fraction) is allocated directly in the major heap, bypassing the minor heap and its copy entirely.

(* allocated directly in the major heap *)
let big = Array.make 1_000_000 0

A Bytes.t or Bigarray backing store is a custom block: its payload lives outside the OCaml heap (often malloc\'d, or mmap\'d for a Bigarray), and only a small header lives inside it. The GC does not see the external bytes at all - it relies on a finalizer function attached to the custom block to know how much external memory to report.

let buf = Bytes.create (10 * 1024 * 1024) (* 10MB, mostly off-heap *)

Which is why a program holding large Bigarrays can show a small major_words in Gc.stat while actually using gigabytes of RAM - the external size has to be read separately.

let arr = Bigarray.Array1.create Bigarray.float64 Bigarray.c_layout 10_000_000
(* Gc.stat under-reports this allocation; check process RSS instead *)

Check actual resident memory from outside the process when custom blocks are involved, since Gc.stat is measuring the wrong thing.

$ ps -o rss= -p $(pgrep -f main.exe)
§ 10

Runtime events, for live monitoring

OCaml 5\'s runtime_events lets a separate process attach to a running one and read GC events live, without restarting it under a profiler.

let () = Runtime_events.start ()

A minimal consumer, run as a second process pointed at the first\'s PID, printing every minor collection as it happens.

let () =
let cursor = Runtime_events.create_cursor (Some (".", target_pid)) in
let callbacks =
Runtime_events.Callbacks.create ()
|> Runtime_events.Callbacks.add_runtime_end (fun _domain _ts phase ->
match phase with
| Runtime_events.EV_MINOR -> print_endline "minor GC finished"
| _ -> ())
in
while true do
ignore (Runtime_events.read_poll cursor callbacks None)
done

Run the target with events enabled, and the observer against its PID, in two terminals.

$ OCAML_RUNTIME_EVENTS_START=1 dune exec ./main.exe &
$ dune exec ./observer.exe -- $(pgrep -f main.exe)

Sample Gc.quick_stat periodically rather than reaching for Gc.stat in a hot path; watch major_words for a climb with no plateau as the leak signature; raise s in OCAMLRUNPARAM first if minor collections are frequent and cheap allocation still shows up in a profile; and reach for memtrace the moment "something is leaking" needs to become "this call site is leaking." Effect handlers and eio fibers (see Effect Handlers in OCaml) are a common source of exactly this kind of long-lived allocation.