Understanding OCaml's Memory Model
OCaml's memory management stands as one of the language's most elegant features. Unlike languages that force developers to manually allocate and deallocate memory, OCaml employs an automatic garbage collector (GC) that traces live objects and reclaims unreachable ones. However, understanding the underlying model is crucial for writing performant, predictable code.
At its core, OCaml uses a generational garbage collector with two primary heap spaces: the minor heap (for short-lived objects) and the major heap (for objects that survive multiple collections). The memory model is built around immutable values by default, which simplifies allocation patterns and enables optimizations that mutable-heavy languages cannot achieve.
OCaml's Memory Layout
Every OCaml value lives in memory as a word-sized block with a header tag. The header encodes the value's type (for sum types and variant constructors) and its size. For immediate values like integers, booleans, and characters, OCaml uses tagged pointers — the value fits directly into a register or stack slot without heap allocation. This is why integer arithmetic is blazingly fast in OCaml: no boxing overhead.
Here's a simplified view of how values are classified:
- Immediate values: integers (31 or 63 bits depending on architecture), booleans, characters, and unit — these never allocate on the heap
- Structured blocks: tuples, records, arrays, strings, and variant constructors with payloads — these live on the heap with a header word
- Abstract blocks: custom data types allocated via C bindings or the
Objmodule
Immediate Values in Action
The distinction between immediate and heap-allocated values has profound performance implications. Consider this example:
(* These are all immediate values — zero heap allocation *)
let a = 42 (* tagged integer *)
let b = true (* immediate boolean *)
let c = 'x' (* immediate character *)
let d = () (* unit value *)
(* These allocate on the heap *)
let e = (42, "hello") (* tuple — heap-allocated block *)
let f = [1; 2; 3] (* list — each cons cell is a heap block *)
let g = {| x: int = 0 |} (* record — heap-allocated block *)
(* OCaml integers are one bit narrower due to the tag *)
let max_int = Sys.int_size (* 63 bits on 64-bit systems, 31 on 32-bit *)
let actual_int_bits = Sys.int_size - 1
The tagged integer representation means that on a 64-bit system, OCaml integers use 63 bits for the value and 1 bit for the tag. This is why Int.max_int is slightly smaller than what the hardware natively supports, but the trade-off is exceptional arithmetic performance since no pointer dereferencing is needed.
The Two-Generation Heap Architecture
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →OCaml's garbage collector is a generational, copying collector. Understanding its two heaps is fundamental to writing allocation-efficient OCaml code.
The Minor Heap
The minor heap is a contiguous block of memory (typically 256 KB to 1 MB, configurable via OCAMLRUNPARAM) where all new allocations occur. Allocation on the minor heap is incredibly cheap — it's essentially a pointer bump. The allocator maintains a pointer to the next free slot; to allocate N words, it increments the pointer by N and returns the old value. This is faster than even malloc in C.
When the minor heap fills up, a minor collection triggers. The collector scans the roots (stack and registers) and copies live objects to the major heap (or to a survivor space within the minor heap for the first few collections). Dead objects are simply forgotten — no explicit deallocation work is needed.
(* This function allocates heavily on the minor heap *)
let create_list n =
let rec loop i acc =
if i = 0 then acc
else loop (i - 1) (i :: acc)
in
loop n []
(* Each :: cons cell is 2 words (header + head value pointer + tail pointer)
For n = 1_000_000, that's ~16 MB of allocation in the minor heap.
Minor collections will be frequent but very fast. *)
let million_items = create_list 1_000_000
The Major Heap
Objects that survive a minor collection are promoted to the major heap. The major heap uses a mark-sweep-compact strategy with an incremental, concurrent collector (in OCaml 5.x) to avoid long GC pauses. The major heap is larger and collections happen less frequently but take more time.
Promotion is automatic and transparent, but it has consequences. An object that is promoted and then becomes unreachable won't be reclaimed until a major collection runs. If you're creating many intermediate data structures that survive just one minor collection, you may see higher memory usage than expected.
(* This pattern causes unnecessary promotion *)
let process_data data =
(* Step 1: allocate intermediate list *)
let intermediate = List.map (fun x -> x * 2) data in
(* Step 2: filter — another intermediate *)
let filtered = List.filter (fun x -> x > 10) intermediate in
(* Step 3: fold to final result *)
List.fold_left (+) 0 filtered
(* Each intermediate list may be promoted if a minor GC runs between steps.
Better: fuse the operations to avoid intermediate allocations. *)
let process_data_fused data =
List.fold_left (fun acc x ->
let doubled = x * 2 in
if doubled > 10 then acc + doubled else acc
) 0 data
Garbage Collector Parameters and Tuning
OCaml exposes several GC parameters that developers can tune. These are controlled through the Gc module or environment variables.
(* Querying current GC settings *)
let () =
let gc_stats = Gc.stat () in
Printf.printf "Minor heap size: %d words\n" (Gc.get ()).minor_heap_size;
Printf.printf "Major heap size: %d words\n" gc_stats.Gc.heap_words;
Printf.printf "Minor collections: %d\n" gc_stats.minor_collections;
Printf.printf "Major collections: %d\n" gc_stats.major_collections
(* Programmatic GC control *)
let () =
(* Trigger a full major collection *)
Gc.full_major ();
(* Compact the major heap *)
Gc.compact ();
(* Set minor heap size to 2 million words (~16 MB on 64-bit) *)
Gc.set { (Gc.get ()) with Gc.minor_heap_size = 2_000_000 }
(* Using OCAMLRUNPARAM environment variable:
export OCAMLRUNPARAM="s=2M,o=480"
s = minor heap size, o = space overhead percentage *)
The Gc module also provides finalisers for resource cleanup:
(* Registering a finaliser for a heap value *)
let allocate_buffer () =
let buf = Bytes.create 4096 in
(* The finaliser runs when buf becomes unreachable *)
Gc.finalise (fun b ->
Printf.eprintf "Cleaning up buffer of size %d\n" (Bytes.length b);
(* Release external resources here *)
) buf;
buf
(* CAUTION: Finalisers run in an unpredictable order and timing.
Do not rely on them for critical resource management.
Use explicit cleanup patterns instead. *)
Understanding GC Latency and OCaml 5.x Improvements
Prior to OCaml 5.0, the major collector was a stop-the-world mark-sweep collector, which could cause noticeable pauses in latency-sensitive applications. OCaml 5.x introduced an incremental, concurrent major collector that interleaves GC work with application code, dramatically reducing pause times.
(* Demonstrating GC pause measurement *)
let measure_gc_pause () =
let start = Sys.time () in
(* Force a major collection *)
Gc.full_major ();
let finish = Sys.time () in
Printf.printf "Full major GC pause: %.6f seconds\n" (finish -. start)
(* In OCaml 5.x, you can configure the concurrent GC:
export OCAMLRUNPARAM="c=1" (* enable concurrent major GC *) *)
Memory Profiling and Debugging
OCaml ships with powerful tools for understanding memory behavior. The Gc module provides runtime statistics, and external tools like ocaml-memory-profiler or the built-in Spacetime profiler (deprecated in 5.x in favor of better tooling) help visualize allocation patterns.
(* Collecting detailed GC statistics *)
let run_with_stats f =
let before = Gc.stat () in
let result = f () in
let after = Gc.stat () in
let allocated = after.Gc.allocated_words - before.Gc.allocated_words in
let promoted = after.Gc.promoted_words - before.Gc.promoted_words in
Printf.printf "Words allocated: %d\n" allocated;
Printf.printf "Words promoted to major heap: %d\n" promoted;
Printf.printf "Minor collections during execution: %d\n"
(after.minor_collections - before.minor_collections);
result
(* Example usage *)
let () =
let data = run_with_stats (fun () ->
List.init 1_000_000 (fun i -> i * 2)
) in
Printf.printf "List length: %d\n" (List.length data)
Common Memory Leak Patterns
While OCaml's GC prevents true memory leaks (dangling pointers), you can still experience memory bloat — situations where memory usage grows unbounded because references are retained longer than needed.
(* LEAK PATTERN 1: Accumulating state without bound *)
let bad_accumulator () =
let cache = ref [] in
for i = 1 to 1_000_000 do
cache := (i, Bigarray.(Array1.create float64 fortran_layout 1000)) :: !cache
done;
(* The cache grows forever — memory usage balloons *)
!cache
(* FIX: Use a bounded cache with eviction *)
let bounded_accumulator () =
let cache = Hashtbl.create 1000 in
for i = 1 to 1_000_000 do
let key = i mod 1000 in
Hashtbl.replace cache key
(Bigarray.(Array1.create float64 fortran_layout 100))
done;
cache
(* LEAK PATTERN 2: Lazy values holding large data *)
let expensive_computation () =
(* Simulate a large computation *)
List.init 10_000_000 (fun i -> float_of_int i *. 2.0)
let lazy_bomb = lazy (expensive_computation ())
(* The lazy value holds the closure and all captured data until forced.
If the lazy is never forced, the captured data still occupies memory. *)
(* FIX: Avoid lazy for large computations; compute eagerly or use weak refs *)
Advanced: Weak References and Ephemerons
OCaml provides weak references and ephemerons for advanced memory management scenarios where you want the GC to collect values that are only referenced from certain structures (like caches).
(* Weak pointers: the GC may collect the value *)
let create_weak_cache () =
let cache = Weak.create 100 in
(* Store values without preventing GC collection *)
for i = 0 to 99 do
Weak.set cache i (Some (List.init 1000 (fun j -> j * i)))
done;
cache
(* Later, entries may become None if the GC collected them *)
let lookup_weak cache i =
match Weak.get cache i with
| Some data -> Some data
| None ->
(* Value was collected; recompute *)
let recomputed = List.init 1000 (fun j -> j * i) in
Weak.set cache i (Some recomputed);
Some recomputed
(* Ephemerons: key-value pairs where the value is collected when the key dies *)
let ephemeron_example () =
let module E = Ephemeron.K1 in
let key = Array.make 1 0 in (* heap-allocated key *)
let value = Array.make 1000 0.0 in
let e = E.create () in
E.set_key e key;
E.set_data e value;
(* When 'key' becomes unreachable, 'value' becomes collectable *)
e
Ephemerons are particularly useful for implementing caches that automatically drop entries when their keys are no longer referenced elsewhere in the program.
Best Practices for Memory-Efficient OCaml
1. Prefer Tail Recursion
Non-tail-recursive functions build up stack frames and often intermediate heap allocations. Tail recursion keeps memory usage constant.
(* Non-tail-recursive: O(n) stack and potentially heap for intermediate results *)
let rec sum_list lst =
match lst with
| [] -> 0
| h :: t -> h + sum_list t (* addition happens AFTER recursive call *)
(* Tail-recursive: O(1) stack space *)
let sum_list_tr lst =
let rec loop acc = function
| [] -> acc
| h :: t -> loop (acc + h) t (* accumulation happens BEFORE recursive call *)
in
loop 0 lst
2. Fuse List Operations
Chaining List.map, List.filter, and List.fold creates intermediate lists. Use a single fold or a pipeline library like Base.Sequence to avoid this.
(* Bad: creates two intermediate lists *)
let result = my_list
|> List.map (fun x -> x * 2)
|> List.filter (fun x -> x > 10)
|> List.fold_left (+) 0
(* Good: single pass, no intermediate lists *)
let result = List.fold_left (fun acc x ->
let doubled = x * 2 in
if doubled > 10 then acc + doubled else acc
) 0 my_list
(* Even better: use lazy sequences for large datasets *)
let result = my_list
|> Seq.of_list
|> Seq.map (fun x -> x * 2)
|> Seq.filter (fun x -> x > 10)
|> Seq.fold_left (+) 0
3. Choose the Right Data Structures
- Arrays (dense, cache-friendly) vs Lists (sparse, pointer-chasing)
- Hashtbl vs Map (balanced BST) depending on access patterns
- Bigarrays for large numeric arrays that benefit from unboxed, flat memory layout
(* List: each element is a separate heap block, pointer-heavy *)
let list_of_floats = List.init 1_000_000 (fun _ -> Random.float 1.0)
(* Memory usage: ~16 MB for the floats + ~16 MB for list cells = ~32 MB *)
(* Array of floats: single heap block, cache-efficient *)
let array_of_floats = Array.init 1_000_000 (fun _ -> Random.float 1.0)
(* Memory usage: ~8 MB — each float is boxed in an OCaml float array though *)
(* Bigarray for unboxed floats: best for numeric work *)
let big_array =
Bigarray.(Array1.create float64 fortran_layout 1_000_000)
let () =
for i = 0 to 999_999 do
Bigarray.Array1.set big_array i (Random.float 1.0)
done
(* Memory usage: exactly 8 MB — unboxed doubles, no OCaml headers *)
4. Avoid Unnecessary Boxing
OCaml boxes most structured types, but you can avoid boxing in specific cases:
(* Boxed tuple — heap allocated *)
let pair x y = (x, y)
(* Unboxed using records with [@@unboxed] (OCaml 4.x+ with flambda) *)
type unboxed_pair = { up_x : int; up_y : int } [@@unboxed]
(* The record fields are passed in registers when flambda is enabled *)
(* For performance-critical numeric code, use the primitive types directly *)
let fast_average (arr : float array) =
let sum = ref 0.0 in
for i = 0 to Array.length arr - 1 do
sum := !sum +. arr.(i)
done;
!sum /. float_of_int (Array.length arr)
5. Be Mindful of Closure Captures
Closures capture their free variables, which can keep large data structures alive:
(* The closure captures 'large_data' — it stays alive as long as the closure exists *)
let create_processor large_data =
let index = build_index large_data in (* large_data is captured *)
fun query -> lookup index query
(* If large_data isn't needed after building the index, explicitly drop it *)
let create_processor_better large_data =
let index = build_index large_data in
(* Drop reference to large_data so GC can collect it *)
let () = (large_data : 'a -> unit) (fun _ -> ()) in (* dummy use to suppress warning *)
fun query -> lookup index query
(* Better pattern: avoid capturing large_data at all *)
let create_processor_best large_data =
let index = build_index large_data in
(* large_data not captured by the returned closure *)
fun query -> lookup index query
6. Use Explicit Resource Management for External Resources
The GC manages OCaml heap memory, but file descriptors, sockets, and GPU buffers need explicit cleanup:
(* RAII-like pattern using a closure-based resource manager *)
let with_file filename f =
let ch = open_in filename in
let result =
try f ch
with exn ->
close_in ch;
raise exn
in
close_in ch;
result
(* Usage *)
let read_first_line filename =
with_file filename (fun ch ->
try Some (input_line ch)
with End_of_file -> None
)
(* For more complex resources, consider the 'Managed' pattern *)
type managed_resource = {
acquire : unit -> 'a;
release : 'a -> unit;
}
let with_resource (r : managed_resource) f =
let res = r.acquire () in
match f res with
| result -> r.release res; result
| exception exn -> r.release res; raise exn
7. Profile Before Optimizing
Always measure memory behavior before applying optimizations. Use Gc.stat, Gc.allocated_bytes, and external tools to understand where allocations happen.
(* Simple allocation profiler *)
let with_allocation_tracking name f =
let before = Gc.allocated_bytes () in
let result = f () in
let after = Gc.allocated_bytes () in
let allocated_mb = (after -. before) /. (1024. *. 1024.) in
Printf.printf "[%s] Allocated: %.2f MB\n" name allocated_mb;
result
(* Usage *)
let () =
let _ = with_allocation_tracking "list_map" (fun () ->
List.map (fun x -> x * 2) (List.init 1_000_000 (fun i -> i))
) in
let _ = with_allocation_tracking "array_map" (fun () ->
Array.map (fun x -> x * 2) (Array.init 1_000_000 (fun i -> i))
) in
()
Multicore Memory Management in OCaml 5.x
OCaml 5.x introduced multicore support with domains (shared-nothing parallelism) and an effects system. Memory management in multicore OCaml requires understanding domain-local heaps and the new garbage collector design.
(* Each domain has its own minor heap — allocations are domain-local *)
let parallel_sum arrays =
let domains = Array.length arrays in
let results = Array.make domains 0.0 in
let barrier = Domain.DLS.new_key (fun () -> ()) in
let domains_list = List.init (domains - 1) (fun id ->
Domain.spawn (fun () ->
let local_sum = Array.fold_left (+.) 0.0 arrays.(id) in
results.(id) <- local_sum
)
) in
(* Main domain processes its chunk *)
results.(domains - 1) <- Array.fold_left (+.) 0.0 arrays.(domains - 1);
List.iter Domain.join domains_list;
Array.fold_left (+.) 0.0 results
The major heap is shared across domains, and the concurrent GC manages it without stopping all domains simultaneously. This means allocation on one domain doesn't trigger GC pauses on others — a significant improvement for latency-sensitive parallel applications.
Memory Consistency and Atomic Operations
When sharing mutable state between domains, you must use atomic operations or synchronization primitives. Plain reference cells are not safe for concurrent access without locks.
(* UNSAFE: race condition on shared ref *)
let unsafe_counter domains =
let counter = ref 0 in
let workers = List.init domains (fun _ ->
Domain.spawn (fun () ->
for i = 1 to 10000 do
counter := !counter + 1 (* read-modify-write race! *)
done
)
) in
List.iter Domain.join workers;
!counter (* will likely be less than domains * 10000 *)
(* SAFE: using atomic counters *)
let safe_counter domains =
let counter = Atomic.make 0 in
let workers = List.init domains (fun _ ->
Domain.spawn (fun () ->
for i = 1 to 10000 do
Atomic.incr counter (* atomic increment *)
done
)
) in
List.iter Domain.join workers;
Atomic.get counter (* exactly domains * 10000 *)
Conclusion
Memory management in OCaml is a sophisticated blend of automatic garbage collection and carefully designed value representations. The generational collector with its pointer-bump minor heap achieves allocation speeds rivaling stack allocation in other languages, while the tagged integer representation eliminates boxing overhead for arithmetic. Understanding these mechanisms empowers you to write OCaml code that is both elegant and performant.
The key takeaways are: allocate mindfully by fusing list operations and choosing appropriate data structures, profile before optimizing with the built-in Gc module, leverage weak references and ephemerons for cache patterns, and embrace the multicore memory model for parallel workloads. OCaml's memory model rewards developers who understand its nuances with predictable, efficient runtime behavior. By treating memory awareness as a fundamental part of OCaml development rather than an afterthought, you'll build applications that scale gracefully and perform admirably across single-core and multicore deployments alike.