Created
July 9, 2026 10:24
-
-
Save damiendoligez/6873546560f5b45d4fe171bd48b1a935 to your computer and use it in GitHub Desktop.
OCaml: simple benchmark for measuring GC response to o parameter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (* Simple GC benchmark. | |
| after first iteration, live memory is constant at 100M words (800M bytes) | |
| We are testing the handling of off-heap memory and ephemerons by the GC | |
| in various proportions. | |
| We have 3 parameters: | |
| e1 : ratio of off-heap to on-heap allocations | |
| e2 : ratio of ephemerons to on-heap allocations | |
| s : size of allocations | |
| Note that the parameters are constrained by the data structure: | |
| - We use a 3-tuple (4 words) for each allocation. | |
| - The bigarray has a 7-word block on the heap. | |
| - The weak array has 3 words of overhead. | |
| - The normal array has 1 word of overhead and at least one pointer. | |
| The total is 16 words for each allocation, so | |
| e1 <= (s-16) / 16 | |
| e2 <= (s-16) / s | |
| *) | |
| external max_rss : unit -> int64 = "rusage_maxrss" | |
| let clip x (min, max) = | |
| if x < min then min | |
| else if x > max then max | |
| else x | |
| (* Size of each allocation set *) | |
| let s = 1000 | |
| (* off-heap ratio: 0.0 to 4.0 Note that 4.0 is unreasonably large. *) | |
| let e1 = 0.0 | |
| (* ephemeron ratio: 0.0 to 0.7 Note that 0.7 is unreasonably large. *) | |
| let e2 = 0.05 | |
| (* Number of elements *) | |
| (* Note that s*n must be 100M, modulo rounding errors. *) | |
| let n = 100_000_000 / s | |
| ;; assert (n <= 1_000_000) | |
| ;; Printf.printf "s: %d\nn: %d\n" s n | |
| (* data structure overhead *) | |
| let s_ovh = 16 | |
| (* s_off = size of off-heap allocation *) | |
| (* We have e1 = s_off / (s-s_off) so s_off = s * e1/(e1+1) *) | |
| let s_off = int_of_float (float s *. e1 /. (e1 +. 1.)) | |
| let s_off = clip s_off (0, s - s_ovh) | |
| (* s_on = size of on-heap allocations | |
| s_on + s_off = s | |
| *) | |
| let s_on = s - s_off | |
| ;; assert (s_on >= s_ovh) | |
| (* s_ephe = size of ephemeron allocations *) | |
| (* we have e2 = s_ephe / s_on so s_ephe = e2 * s_on *) | |
| let s_ephe = int_of_float (e2 *. float s_on) | |
| let s_ephe = clip s_ephe (0, s_on - s_ovh) | |
| let s_ephe = if s_ephe < 4 then 0 else s_ephe | |
| (* s_norm = size of normal array allocations, not counting the first field *) | |
| let s_norm = s_on - s_ovh - s_ephe | |
| ;; assert (s_norm >= 0) | |
| ;; assert (s_on = s_norm + s_ovh + s_ephe) | |
| ;; Printf.printf "s_on: %d\ns_norm: %d\ns_off: %d\ns_ephe: %d\ns_ovh: %d\n" | |
| s_on s_norm s_off s_ephe s_ovh | |
| let actual_e1 = (float s_off /. float s_on) | |
| let actual_e2 = (float s_ephe /. float s_on) | |
| ;; Printf.printf "e1: %.2f\n" actual_e1 | |
| ;; Printf.printf "e2: %.2f\n" actual_e2 | |
| ;; Printf.printf "title: s:%d off:%.2f ephe:%.2f V=%s\n" | |
| s actual_e1 actual_e2 Sys.ocaml_version | |
| (* Size of on-heap data: live stuff plus the 1M array *) | |
| let on_sz = 1 + n * s_on / 1_000_000 | |
| (* Size of off-heap data. *) | |
| let off_sz = n * s_off / 1_000_000 | |
| ;; Printf.printf "inf: (o/100+1) * %d + %d\n" on_sz off_sz | |
| ;; Printf.printf "sup: (o/100+1.1) * %d + %d\n" on_sz off_sz | |
| ;; flush stdout | |
| let mkweak n = (Weak.create n : int array Weak.t) | |
| let constant_bigarray = Bigarray.(Array1.create float64 c_layout 1) | |
| let constant_weak = mkweak 1 | |
| let make_elem j = | |
| let ba = | |
| if s_off = 0 | |
| then constant_bigarray | |
| else Bigarray.(Array1.create float64 c_layout s_off) | |
| in | |
| for i = 0 to s_off / 100 - 1 do | |
| Bigarray.Array1.set ba (i*100) 1.0 | |
| done; | |
| let w = if s_ephe = 0 then constant_weak else mkweak (s_ephe - 3) in | |
| let ar = | |
| let sz = | |
| s_norm + (if s_off = 0 then 7 else 0) + (if s_ephe = 0 then 3 else 0) | |
| in | |
| assert (sz >= 0); | |
| Array.make (sz+1) j | |
| in | |
| (ba, w, ar) | |
| (* We keep this array at 1M to make everything comparable | |
| with different values of s (and n). | |
| *) | |
| let a = Array.make 1_000_000 (make_elem 0) | |
| let main () = | |
| (* Preheat minor heap. *) | |
| while (Gc.quick_stat ()).minor_collections < 2 do | |
| ignore (Sys.opaque_identity (ref 42)) | |
| done; | |
| (* Allocate and preheat remembered set. *) | |
| while (Gc.quick_stat ()).minor_collections < 4 do | |
| a.(0) <- make_elem 0 | |
| done; | |
| (* Measure the overhead: process size before we start allocating. *) | |
| let rss_before = max_rss () in | |
| let stat_startup = Gc.quick_stat () in | |
| while (Gc.quick_stat ()).major_collections | |
| < stat_startup.major_collections + 50 | |
| do | |
| for j = 0 to n-1 do | |
| a.(j) <- make_elem j; | |
| done | |
| done; | |
| let rss_after = max_rss () in | |
| let rss_alloc = | |
| match Sys.getenv_opt "RAWRSS" with | |
| | None -> Int64.(to_float (sub rss_after rss_before)) | |
| | Some _ -> Int64.to_float rss_after | |
| in | |
| Printf.printf "%.0f maximum resident set size\n" rss_alloc; | |
| Printf.printf "%.0f RSS overhead\n" (Int64.to_float rss_before); | |
| flush stdout; | |
| ;; main () |
I noticed that the RSS is not stable even when the program is unchanged (likely due to the C allocator).
To confirm that the random noise indeed comes from outside of the OCaml GC I called malloc_stats on Linux:
#ifdef __GLIBC__
#include "malloc.h"
#else
static void malloc_stats(void) {}
#endif
value stub_malloc_stats(value u)
{
malloc_stats();
}
--- prog.ml.1 2026-07-10 13:59:05.271295118 +0100
+++ prog1.ml 2026-07-10 13:57:10.004647611 +0100
@@ -20,6 +20,7 @@
*)
external max_rss : unit -> int64 = "rusage_maxrss"
+external malloc_stats: unit -> unit = "stub_malloc_stats"
let clip x (min, max) =
if x < min then min
@@ -29,7 +30,7 @@
(* Size of each allocation set *)
let s = 1000
(* off-heap ratio: 0.0 to 4.0 Note that 4.0 is unreasonably large. *)
-let e1 = 0.0
+let e1 = 0.4
(* ephemeron ratio: 0.0 to 0.7 Note that 0.7 is unreasonably large. *)
let e2 = 0.05
@@ -135,6 +136,7 @@
done;
let rss_after = max_rss () in
+ malloc_stats ();
let rss_alloc =
match Sys.getenv_opt "RAWRSS" with
| None -> Int64.(to_float (sub rss_after rss_before))This will output something like:
Arena 0:
system bytes = 1324744704
in use bytes = 1228628384
Total (incl. mmap):
system bytes = 1333809152
in use bytes = 1237692832
max mmap regions = 3
max mmap bytes = 9064448
1357312 maximum resident set size
15224 RSS overhead
The last system bytes and in use bytes reported remains stable across many runs.
The maximum resident set size changes by about 0.1%:
$ grep 'maximum resident' prog.out.*|cut -f2 -d:|cut -f1 -d ' ' |~/git/ministat/ministat
x <stdin>
+--------------------------------------------------------------------------------------------------------------------+
| x |
| x |
| x |
| x |
| x x x |
| x x x |
| x x x x |
|x x x x x x |
| |____________________________A________M___________________||
+--------------------------------------------------------------------------------------------------------------------+
N Min Max Median Avg Stddev
x 20 1356032 1357824 1357568 1357427.2 473.3177
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This tweaks some of the constant overheads. On OCaml 5.5
s_ovhshould be 18 instead of 16 (due to the extra overhead fromLARGE_ALLOC_HEADER_SZ).Also the
s_ephe - 3seemed wrong (and resulted in a measured-3words difference between desireds_onand actual), removing it makesmake_elemreturn exactlys_on.Also when weak arrays are >= 126 words they need 2 extra words (this matches with the transition from 1 to 3 words of overhead for regular arrays, 126 + 2 extra words in caml_weak_create = 128).
I haven't checked other OCaml versions.