Skip to content

Allow values reachable from ephemeron keys to be collected by minor GC - #13643

Merged
gasche merged 4 commits into
ocaml:trunkfrom
stedolan:minor-ephe-keys
Apr 6, 2025
Merged

Allow values reachable from ephemeron keys to be collected by minor GC#13643
gasche merged 4 commits into
ocaml:trunkfrom
stedolan:minor-ephe-keys

Conversation

@stedolan

Copy link
Copy Markdown
Contributor

Multicore Ephemeron GC is tricky, requiring a complex multi-round synchronisation to get right. To keep minor GC simple and fast, it punts on the complexity: all values in ephemeron keys or data are unconditionally promoted.

Unfortunately, this policy essentially disables generational GC for values stored in ephemerons (including weak tables). This patch improves the situation by identifying an easy case: the data of an ephemeron is still promoted unconditionally, but the keys are treated as weak references by the minor GC if they point to the current domain's minor heap.

This is not a full implementation of the ephemeron condition: in particular, if the data points to the key both will be promoted. Difficult cases like this are, as before, left to the major GC.

However, it greatly improves the situation for data using Stdlib.Weak arrays (which are implemented as ephemerons with many keys and trivial data), especially for programs using hashconsing with lots of values that are only seen once. Here's a silly program, hashconsing values that are never reused:

module W = Weak.Make (String)
let tbl = W.create 128

let () =
  for i = 1 to 10_000_000 do
    W.add tbl (string_of_int i)
  done

and its approximate runtime/space usage (on a Debian amd64 laptop):

time memory (RSS)
4.14 1.1 s 8 MB
5.0 16.0 s 1053 MB
5.1 5.8 s 170 MB
5.2 5.6 s 160 MB
5.3 5.7 s 190 MB
this PR 1.3 s 9 MB

I haven't bisected this to find exactly what changed, but I think the big improvement from 5.0 to 5.1 is due to @kayceesrk in #11743 and @NickBarnes in #12131. The results are noisy enough that I wouldn't read much into the differences between 5.1/5.2/5.3.

Note for reviewers: Start by reading the big comment and the new block of code in oldify_mopup in minor_gc.c. This is the core logic of the patch. Everything else is:

  • cleaning up after that logic (ephe_clean_minor and the other changes in minor_gc.c)
  • handling the case where it has not yet been cleaned up (the changes in weak.c)
  • testing that that change works (the new test in weak-ephe-final)

@jberdine

Copy link
Copy Markdown
Contributor

Out of curiosity I did a quick check of what effect this change has on the sandmark ZDD benchmark.

I'm not sure what to make of it, maybe noise, but for this case it seems that trunk is faster:

Benchmark 1: ./pr.exe words.txt
  Time (mean ± σ):      2.975 s ±  0.026 s    [User: 2.866 s, System: 0.104 s]
  Range (min … max):    2.929 s …  3.017 s    10 runs

Benchmark 2: ./414.exe words.txt
  Time (mean ± σ):      1.983 s ±  0.164 s    [User: 1.943 s, System: 0.037 s]
  Range (min … max):    1.803 s …  2.188 s    10 runs

Benchmark 3: ./trunk.exe words.txt
  Time (mean ± σ):      2.731 s ±  0.024 s    [User: 2.627 s, System: 0.097 s]
  Range (min … max):    2.707 s …  2.783 s    10 runs

Summary
  ./414.exe words.txt ran
    1.38 ± 0.11 times faster than ./trunk.exe words.txt
    1.50 ± 0.12 times faster than ./pr.exe words.txt

FTR these were all compiled with flambda -O3, and run on a mac with an M3 Max.

@jberdine

Copy link
Copy Markdown
Contributor

I locally rebased this over #13740 and #13737 and ran the benchmark again. On top of those, this PR is very close in performance on that benchmark, seemingly around only 3% slower. The allocation stats indicate that the actual behavioral improvement of this PR does not take effect on this benchmark (all stats essentially the same, notably ~40% promotion rate), so this result serves as a way to indicate how much overhead the additional tracking this PR does for ephemerons in the case that it doesn't help. So nicely low. I do not yet understand why the behavioral improvement does not take effect on this benchmark.

> hyperfine -w 3 './13740.exe /usr/share/dict/words' './13643.exe /usr/share/dict/words'
Benchmark 1: ./13740.exe /usr/share/dict/words
  Time (mean ± σ):     14.158 s ±  0.167 s    [User: 13.622 s, System: 0.528 s]
  Range (min … max):   13.926 s … 14.443 s    10 runs

Benchmark 2: ./13643.exe /usr/share/dict/words
  Time (mean ± σ):     14.600 s ±  0.099 s    [User: 14.053 s, System: 0.539 s]
  Range (min … max):   14.488 s … 14.777 s    10 runs

Summary
  ./13740.exe /usr/share/dict/words ran
    1.03 ± 0.01 times faster than ./13643.exe /usr/share/dict/words
> OCAMLRUNPARAM='v=0x400' /usr/bin/time -hl ./13740.exe /usr/share/dict/words
Unique table length: 1521436 entries: 7870174 bucket lengths: sum: 10557750 min: 0 median: 7 max: 22
315
allocated_words: 767965180
minor_words: 607371446
promoted_words: 247054234
major_words: 407647968
minor_collections: 2370
major_collections: 9
forced_major_collections: 0
heap_words: 406250898
top_heap_words: 406250898
        14.05s real             13.43s user             0.61s sys
          3260923904  maximum resident set size
                   0  average shared memory size
                   0  average unshared data size
                   0  average unshared stack size
              199154  page reclaims
                  22  page faults
                   0  swaps
                   0  block input operations
                   0  block output operations
                   0  messages sent
                   0  messages received
                   0  signals received
                   6  voluntary context switches
                 159  involuntary context switches
        108385561904  instructions retired
         52754835261  cycles elapsed
          3261717888  peak memory footprint
> OCAMLRUNPARAM='v=0x400' /usr/bin/time -hl ./13643.exe /usr/share/dict/words
Unique table length: 1521436 entries: 7870174 bucket lengths: sum: 10557750 min: 0 median: 7 max: 22
315
allocated_words: 767965179
minor_words: 607371445
promoted_words: 247054233
major_words: 407647967
minor_collections: 2370
major_collections: 9
forced_major_collections: 0
heap_words: 406250898
top_heap_words: 406250898
        14.28s real             13.67s user             0.60s sys
          3261186048  maximum resident set size
                   0  average shared memory size
                   0  average unshared data size
                   0  average unshared stack size
              199170  page reclaims
                  22  page faults
                   0  swaps
                   0  block input operations
                   0  block output operations
                   0  messages sent
                   0  messages received
                   0  signals received
                   0  voluntary context switches
                  80  involuntary context switches
        118112510972  instructions retired
         53735511295  cycles elapsed
          3261963584  peak memory footprint

@kayceesrk kayceesrk added the Performance PR or issues affecting runtime performance of the compiled programs label Jan 18, 2025
@stedolan

Copy link
Copy Markdown
Contributor Author

Thanks for the benchmarking work here @jberdine! It was extremely helpful.

I reproduced the same effect, seeing no real difference with the patch:

$ /usr/bin/time -f '%e seconds' ./zdd-trunk words.txt 
Unique table length: 300526 entries: 1864867 bucket lengths: sum: 2467890 min: 0 median: 7 max: 22
69
4.46 seconds

$ /usr/bin/time -f '%e seconds' ./zdd-patch words.txt 
Unique table length: 300526 entries: 1864867 bucket lengths: sum: 2467890 min: 0 median: 7 max: 22
69
4.48 seconds

Note that not only do these runs have the same elapsed time, they also agree on the weak table stats.

Looking at the code of zdd.ml, I noticed that it puts essentially every weakly-reachable value it creates into an ephemeron: every call to unique (the one that uses Weak) is the return value of something using Memo[12].find_or_add. This makes these values reachable from the data field of an ephemeron, which defeats the optimisation in this PR.

So, I removed the memoization from zdd.ml using this patch:

--- zdd_orig.ml	2025-01-20 15:44:18.611777747 +0000
+++ zdd.ml	2025-01-20 15:44:38.080741235 +0000
@@ -54,12 +54,15 @@
     let node = Ite {v; t; e; hash} in
     WeakSet.merge unique_set node
 
+let no_memo = Sys.getenv_opt "NO_MEMO" <> None
+
 module Memo1 = struct
   module Tbl = Ephemeron.K1.Make (T)
 
   let create = Tbl.create
 
   let find_or_add tbl p k =
+    if no_memo then k () else
     match Tbl.find_opt tbl p with
     | Some r -> r
     | None ->
@@ -74,6 +77,7 @@
   let create = Tbl.create
 
   let find_or_add tbl p q k =
+    if no_memo then k () else
     match Tbl.find_opt tbl (p, q) with
     | Some r -> r
     | None ->

This made the program much faster:

$ NO_MEMO=1 /usr/bin/time -f '%e seconds' ./zdd-trunk words.txt 
Unique table length: 200349 entries: 802235 bucket lengths: sum: 1106075 min: 0 median: 7 max: 22
69
1.78 seconds

Ephemeron memoization does have some overhead but I'm surprised it's this large! However, even in NO_MEMO mode there was still no difference with this patch.

The reason for this turns out to be that the patch is broken. This diff fixes it:

diff --git a/runtime/minor_gc.c b/runtime/minor_gc.c
index b6f936865a..e42f20e79c 100644
--- a/runtime/minor_gc.c
+++ b/runtime/minor_gc.c
@@ -455,7 +455,7 @@ again:
           re->offset != CAML_EPHE_DATA_OFFSET && /* ephe key (not data)  */
           Is_block(v) &&                         /* a block              */
           young_start <= v && v < young_end &&   /* on *this* minor heap */
-          Tag_val(v) != 0 &&                     /* not already promoted */
+          Hd_val(v) != 0 &&                      /* not already promoted */
           atomic_compare_exchange_strong(data, &v, caml_ephe_locked)) {
         /* locked, clean it later */
         re->locked = v;

The previous version of the patch was not correctly checking for promoted values (whose header is overwritten with zero), but instead checking that Tag_val(v) != 0. This means that the optimisation could never apply to tuples, records, arrays, or any other value with tag 0. (My testcase happened to test strings, which have nonzero tag, so it worked there). With the fix applied, and in NO_MEMO=1 mode, the patch finally makes a difference:

$ NO_MEMO=1 /usr/bin/time -f '%e seconds' ./zdd-patch words.txt 
Unique table length: 26379 entries: 108643 bucket lengths: sum: 148732 min: 0 median: 7 max: 22
69
0.69 seconds

The table is about 10x smaller and the runtime 2.5x faster than trunk in NO_MEMO=1 mode.

@lthls

lthls commented Jan 20, 2025

Copy link
Copy Markdown
Contributor

Ephemeron memoization does have some overhead but I'm surprised it's this large!

This benchmark is about 90% ephemeron overhead in the best case; if I remove all ephemerons and weak tables (i.e. replace WeakSet.merge unique_set node with node in addition to the NO_MEMO patch) I get a 10x speedup on my laptop. On 5.x this overhead is bigger, so the performance is worse, but (hopefully) real programs would have a much higher ratio of useful work / memoizing overhead.
This does make the benchmark very useful to measure the performance of ephemerons though.

@jberdine

jberdine commented Feb 1, 2025

Copy link
Copy Markdown
Contributor

Ephemeron memoization does have some overhead but I'm surprised it's this large!

This benchmark is about 90% ephemeron overhead in the best case; if I remove all ephemerons and weak tables (i.e. replace WeakSet.merge unique_set node with node in addition to the NO_MEMO patch) I get a 10x speedup on my laptop. On 5.x this overhead is bigger, so the performance is worse, but (hopefully) real programs would have a much higher ratio of useful work / memoizing overhead. This does make the benchmark very useful to measure the performance of ephemerons though.

It is completely true that this benchmark does not benefit from the operation caching. I don't know if it is particularly useful for this PR as it also has the behavior where each element of the Weak array is immediately referenced (as data not just key) from an operation cache, but I proposed a more representative one ocaml-bench/sandmark#478 where the cache does effectively turn exptime operations into polytime.

Comparing this PR vs trunk vs 4.14 using that benchmark:

> hyperfine -w 3 -L m 2,1 -L n 2,1 -L v 414,trunk,pr13643 'MEMO_HC={m} MEMO_OP={n} MEMO_SCALE=128 ./zdd_queens_{v}.exe 8'
Benchmark 1: MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_414.exe 8
  Time (mean ± σ):      9.359 s ±  0.321 s    [User: 9.175 s, System: 0.171 s]
  Range (min … max):    8.944 s …  9.774 s    10 runs

Benchmark 2: MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_414.exe 8
  Time (mean ± σ):      8.482 s ±  0.062 s    [User: 8.306 s, System: 0.171 s]
  Range (min … max):    8.355 s …  8.555 s    10 runs

Benchmark 3: MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_414.exe 8
  Time (mean ± σ):     14.449 s ±  0.545 s    [User: 14.267 s, System: 0.173 s]
  Range (min … max):   13.975 s … 15.479 s    10 runs

Benchmark 4: MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_414.exe 8
  Time (mean ± σ):     14.406 s ±  0.253 s    [User: 14.245 s, System: 0.155 s]
  Range (min … max):   13.918 s … 14.767 s    10 runs

Benchmark 5: MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
  Time (mean ± σ):      8.012 s ±  0.034 s    [User: 7.801 s, System: 0.205 s]
  Range (min … max):    7.962 s …  8.079 s    10 runs

Benchmark 6: MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
  Time (mean ± σ):      8.252 s ±  0.027 s    [User: 8.036 s, System: 0.211 s]
  Range (min … max):    8.206 s …  8.293 s    10 runs

Benchmark 7: MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
  Time (mean ± σ):     12.377 s ±  0.068 s    [User: 12.207 s, System: 0.165 s]
  Range (min … max):   12.284 s … 12.493 s    10 runs

Benchmark 8: MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
  Time (mean ± σ):     12.750 s ±  0.245 s    [User: 12.567 s, System: 0.173 s]
  Range (min … max):   12.497 s … 13.401 s    10 runs

Benchmark 9: MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
  Time (mean ± σ):      7.602 s ±  0.033 s    [User: 7.414 s, System: 0.184 s]
  Range (min … max):    7.544 s …  7.641 s    10 runs

Benchmark 10: MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
  Time (mean ± σ):      7.773 s ±  0.038 s    [User: 7.580 s, System: 0.187 s]
  Range (min … max):    7.724 s …  7.845 s    10 runs

Benchmark 11: MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
  Time (mean ± σ):     12.020 s ±  0.068 s    [User: 11.831 s, System: 0.183 s]
  Range (min … max):   11.936 s … 12.158 s    10 runs

Benchmark 12: MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
  Time (mean ± σ):     12.566 s ±  0.147 s    [User: 12.340 s, System: 0.220 s]
  Range (min … max):   12.438 s … 12.926 s    10 runs

Summary
  MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8 ran
    1.02 ± 0.01 times faster than MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
    1.05 ± 0.01 times faster than MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
    1.09 ± 0.01 times faster than MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
    1.12 ± 0.01 times faster than MEMO_HC=1 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_414.exe 8
    1.23 ± 0.04 times faster than MEMO_HC=2 MEMO_OP=2 MEMO_SCALE=128 ./zdd_queens_414.exe 8
    1.58 ± 0.01 times faster than MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
    1.63 ± 0.01 times faster than MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
    1.65 ± 0.02 times faster than MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_pr13643.exe 8
    1.68 ± 0.03 times faster than MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_trunk.exe 8
    1.89 ± 0.03 times faster than MEMO_HC=1 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_414.exe 8
    1.90 ± 0.07 times faster than MEMO_HC=2 MEMO_OP=1 MEMO_SCALE=128 ./zdd_queens_414.exe 8

@gasche

gasche commented Feb 1, 2025

Copy link
Copy Markdown
Member

My understanding from this conversation is that @jberdine is now convinced that this PR would be good to have. Let's consider it a consensus (two people agree!); I would be happy to merge if it had been reviewed for correctness, but this is in a tricky part of the runtime and I wouldn't trust myself to do this review (in addition to not having much time to do it for now). If anyone is interested in reviewing for correctness, please be my guest :-)

@gasche

gasche commented Feb 1, 2025

Copy link
Copy Markdown
Member

(It may be that @damiendoligez, @NickBarnes or @bobot would be willing to review the code.)

Comment thread runtime/minor_gc.c
if (new_v != v) {
/* atomic CAS, because another domain might be trying to lock it.
(We don't care who wins the race, so result not checked) */
atomic_compare_exchange_strong(data, &v, new_v);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How could another domain lock it, since it seems a domain only lock keys in its own domain?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's true that domains only lock keys in their own domain. This code runs when v is not in the current domain, so its owning domain may be trying to lock it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, I forgot that oldify_one can work on other domains minor heap. (Still this code can also run if v is in the current domain, no? e.g. DATA_OFFSET)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it also runs in that case. The comment is about why the atomic CAS is needed, which is to handle the case when it is owned by another domain.

Comment thread runtime/minor_gc.c
value v = *data;
if (v != caml_ephe_none && Is_block(v) && Is_young(v) ) {
mlsize_t offs = Tag_val(v) == Infix_tag ? Infix_offset_val(v) : 0;
v -= offs;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the handling of Infix_tag disappeared?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well spotted! I think the code here is correct by coincidence, because Infix_tag values never have zero headers (it always points to the prior header), so the optimisation doesn't apply to Infix_tag. I've changed it to be more explicit about this.

@bobot bobot Mar 7, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok for the optimisation. But when the optimisation is not triggered (the else) why the handling of infix_tag is removed? Only oldify_one is done directly.

EDIT: I looked a little more at oldify_one, and it seems the previous code was just an inlining of the first part of oldify_one. So in other term the previous code could have been written as the following, and this change is independent:

atomic_value* data = Op_atomic_val(re->ephe) + re->offset;
value v = atomic_load_relaxed(data);
value new_v;
oldify_one(st, v, &new_v);
if (new_v != v) {
    atomic_compare_exchange_strong(data, &v, new_v);
    redo = 1
}

@jberdine

jberdine commented Feb 3, 2025

Copy link
Copy Markdown
Contributor

My understanding from this conversation is that @jberdine is now convinced that this PR would be good to have.

Yes, correct!

@nojb

nojb commented Feb 19, 2025

Copy link
Copy Markdown
Contributor

We recently switched to OCaml 5.3.0 at LexiFi. As you may know we use hash consing heavily in the core of our codebase. We now have identified a real-life case where OCaml 5.3.0 takes 1.8x time relative to 4.14.2. With this PR, it takes 2x time relative to 4.14.2 (so it is actually worse than the current 5.3.0 performance).

The timings (in s) are:

4.14.2: 6.44038796425
5.3.0: 11.4317111969
PR: 13.2093420029

Unfortunately, I don't know if it is feasible to make the test self-contained enough so that it can be made available publicly. For what is worth, this is what the perf profile looks under 4.14:

image

and under 5.3.0:

image

@stedolan

Copy link
Copy Markdown
Contributor Author

It's hard to get much of use out of a perf screenshot!

Can you post the GC summary output, as printed when running under OCAMLRUNPARAM=v=0x400, from all three builds? Also it might be useful to see the full GC logs, as printed by OCAMLRUNPARAM=v=0xfff, again from all three builds.

Are you using ephemerons in generality or just as weak pointers? (i.e. do you use the stdlib Ephemeron module or just the Weak module for your hashconsing?)

@nojb

nojb commented Feb 19, 2025

Copy link
Copy Markdown
Contributor

It's hard to get much of use out of a perf screenshot!

Yes, sorry about that.

Can you post the GC summary output, as printed when running under OCAMLRUNPARAM=v=0x400, from all three builds? Also it might be useful to see the full GC logs, as printed by OCAMLRUNPARAM=v=0xfff, again from all three builds.

I will prepare these and upload them here.

Are you using ephemerons in generality or just as weak pointers? (i.e. do you use the stdlib Ephemeron module or just the Weak module for your hashconsing?)

I believe just the Weak module.

@nojb

nojb commented Feb 19, 2025

Copy link
Copy Markdown
Contributor

Can you post the GC summary output, as printed when running under OCAMLRUNPARAM=v=0x400, from all three builds? Also it might be useful to see the full GC logs, as printed by OCAMLRUNPARAM=v=0xfff, again from all three builds.

I will prepare these and upload them here.

@stedolan The requested files are below. Thanks!

GC-full-414.txt
GC-full-530.txt
GC-full-PR.txt
GC-summary-414.txt
GC-summary-530.txt
GC-summary-PR.txt

@stedolan

Copy link
Copy Markdown
Contributor Author

This is very strange! I have some notes and many questions:

  • The allocated_words value is about 40% higher in 5 than in 4.14. This is a value determined by the actions of the program, and is generally unaffected by GC decisions, so I don't understand why it's so different. (Perhaps stdlib differences?)
    Is this definitely running the same code with the same inputs under both 4.14 and 5?

  • The heap size seems to be about 10x larger in 5, which is very unexpected! (800MB vs. 78MB). GC cycles are completing, but I can't tell whether the GC in 5 is running too slowly, or whether the program is holding on to more memory in 5 for some reason.
    Is there any reason why this program might retain data longer in 5?

  • The GC logs show that 5 does hundreds of ephemeron cycles in a single major GC cycle (239 in GC-full-530, 251 in GC-full-PR). This is an unexpectedly large number, and consistent with your perf screenshot showing >20% of time spent in ephe_mark. However, I believe it is impossible to get the ephemeron cycle counter above 1 using only Weak, so this suggests that there is either a bug in weak pointers / ephemeron marking, or heavy use of complex ephemerons (using the Ephemeron module directly).
    Does this program in fact make heavy use of Ephemeron, not just Weak?

  • The weird behaviour in the 5 logs concerns the major heap, yet this patch touches only minor GC. Is it possible that the 15% slowdown you observed is due to some other patch between 5.3.0 and this PR?
    Which exact commits are GC-full-530 and GC-full-PR from?

The biggest differences between 4 and 5 and this PR appear to be in the allocation behaviour and lifetimes of allocated blocks. This information is very visible in a memory profile, so I'd suggest using memtrace, which might make the problem obvious.

@jberdine

Copy link
Copy Markdown
Contributor

@nojb Do I remember correctly that you folks have your own implementation of weak.ml (based on the same runtime system primitives)? If so, do there appear to be significant differences, or is that sharable?

@jberdine

Copy link
Copy Markdown
Contributor

Re allocated_words and heap size being higher, I have seen code using (weak arrays and) ephemerons show changes in these values due to GC decisions. What appeared to happen in the cases I have investigated is that the pace at which the GC collects ephemerons affects the occupancy of the tables built on them. So slower GC pacing leads to more table resizes, which leads to more allocated words and higher heap size.

Is there a way to tell from the stats if Ephe_key is hitting contention?

@nojb

nojb commented Feb 20, 2025

Copy link
Copy Markdown
Contributor
  • Is this definitely running the same code with the same inputs under both 4.14 and 5?

Yes.

The biggest differences between 4 and 5 and this PR appear to be in the allocation behaviour and lifetimes of allocated blocks. This information is very visible in a memory profile, so I'd suggest using memtrace, which might make the problem obvious.

I tried this, but I cannot spot anything obvious (to my untrained eye). Most of the difference in allocation behaviour seems to come from the Weak module. I am attaching the trace files produced by memprof for 4.14 and 5.3:

trace-414.ctf.txt
trace-530.ctf.txt

  • The heap size seems to be about 10x larger in 5, which is very unexpected! (800MB vs. 78MB).

From the memprof trace, this is the allocation profile for OCaml 4.14:

image

and for OCaml 5.3:

image

  • Is there any reason why this program might retain data longer in 5?

Nothing that I know of. In OCaml 4.14 we were using "best fit".

  • The weird behaviour in the 5 logs concerns the major heap, yet this patch touches only minor GC. Is it possible that the 15% slowdown you observed is due to some other patch between 5.3.0 and this PR?

I will recheck and get back.

  • Does this program in fact make heavy use of Ephemeron, not just Weak?

I will recheck and get back.

@nojb

nojb commented Feb 20, 2025

Copy link
Copy Markdown
Contributor

@nojb Do I remember correctly that you folks have your own implementation of weak.ml (based on the same runtime system primitives)? If so, do there appear to be significant differences, or is that sharable?

Indeed, that's the case, but for the purposes of this discussion I replaced our custom version by the one in the standard library to try to make the measurements as reproducible as possible (the difference between the two was small). I will try to share our custom version later anyway.

@stedolan

Copy link
Copy Markdown
Contributor Author

As @jberdine says:

the pace at which the GC collects ephemerons affects the occupancy of the tables built on them. So slower GC pacing leads to more table resizes, which leads to more allocated words and higher heap size.

This does seem to be the explanation for the allocation differences here, which are almost entirely inside Weak.Make.merge:
4.14, 1.5 GB allocated:
4.14
5.3, 3.2 GB allocated:
5.3

The slower GC pacing is consistent with the high number of ephemeron marking cycles observed in both 5.3 and this PR: the GC is spending so much time and so many slices redoing ephemeron marking that it takes a very long time to complete a cycle, causing huge heap growth in the meantime.

The mystery is why the GC is redoing ephemeron marking. Ephemeron marking begins only when the GC has finished all available marking work, and is redone only if new marking work is discovered during ephemeron marking. In principle, the only way that this can occur is if ephemerons with marked keys and not-yet-marked data are discovered. (In particular, new allocations are not sufficient, since new values are allocated MARKED).

This cannot happen here, since Weak uses only ephemerons with trivial data. From the memtrace, this program appears not to use the Ephemeron module at all, only Weak, and so it should not be possible to discover new marking work during ephemeron marking.

Yet it does, more than 200 times in a row.

I think this would not be too hard to figure out by messing around in a debugger for a while, but I understand you can't share the original program. Would it be possible to try to cut it down to something shareable? (To reproduce the issue here, I don't care whether the performance difference is visible: the thing I'd like to have is a program that prints the Mark Ephemeron: Continued from cursor. Ephemeron cycle=<big number> GC log message, while only using Weak and not Ephemeron).

@lthls

lthls commented Feb 21, 2025

Copy link
Copy Markdown
Contributor

I think this would not be too hard to figure out by messing around in a debugger for a while, but I understand you can't share the original program. Would it be possible to try to cut it down to something shareable? (To reproduce the issue here, I don't care whether the performance difference is visible: the thing I'd like to have is a program that prints the Mark Ephemeron: Continued from cursor. Ephemeron cycle=<big number> GC log message, while only using Weak and not Ephemeron).

I believe I have what you want here: https://github.com/lthls/ephemeron_repro

I created it from some files I had made to investigate this issue earlier, and I verified that the setup that is currently committed produces the message you're looking for and only uses Weak, not Ephemeron.

@stedolan

Copy link
Copy Markdown
Contributor Author

Thanks @lthls, that example was very helpful.

The bug here is that OCaml 5 is missing a crucial optimisation when marking ephemerons. Ephemerons cause their data to be marked if all of their keys and the ephemeron block itself are marked. In general, when any marking occurs all ephemerons need to be re-examined to see whether any new data fields must be marked, which can be slow. There are two simple cases where this work need not be redone:

  1. Once the keys and ephe block are marked, and the data consequently marked, that ephemeron need not be examined again this cycle as there is no more work to do.
  2. If the data is already marked or is trivial, that ephemeron need not be examined again as no work can be done (even if the keys / block get marked later).

OCaml 5 implements only optimisation (1) above, not (2). The latter is particularly important for programs using Weak, since all weak pointers are implemented as ephemerons with trivial data.

The other thing that confused me before is the source of the extra marking work that triggers ephemeron remarking - ephemeron marking does not begin until you've run out of marking work, so it should be hard to trigger the re-marking logic. It turns out to be Weak.get: this function turns a weak pointer into a strong pointer, increasing marking work.

I'll try to get a fix up for this in the next day or two.

@stedolan

Copy link
Copy Markdown
Contributor Author

This is now rebased on top of #13827, which is just merged. @nojb would you mind running your benchmark again on the current tip of this PR? (It now has both the minor and major GC weak pointer fixes)

@nojb

nojb commented Feb 25, 2025

Copy link
Copy Markdown
Contributor

This is now rebased on top of #13827, which is just merged. @nojb would you mind running your benchmark again on the current tip of this PR? (It now has both the minor and major GC weak pointer fixes)

Will do.

@nojb

nojb commented Feb 25, 2025

Copy link
Copy Markdown
Contributor

This is now rebased on top of #13827, which is just merged. @nojb would you mind running your benchmark again on the current tip of this PR? (It now has both the minor and major GC weak pointer fixes)

Will do.

The PR seems to be 5% or 10% slower than trunk on our test case. The full GC logs in case they are useful:

GC-full-PR.txt
GC-full-trunk.txt

@bobot

bobot commented Mar 3, 2025

Copy link
Copy Markdown
Contributor

I think #9259 (performance of blit) is only in 4.14 not in 5.*. (Also #9424 but it should already be correct normally)

@gasche

gasche commented Mar 4, 2025

Copy link
Copy Markdown
Member

Personally I would still be in favor of merging a PR that improves the situation dramatically in some cases, at the cost of a 5-10% slowdown in some other cases. (I would rather avoid pathological runtime behaviors.) But we still need a full review and the change is arguably tricky/invasive.

@bobot

bobot commented Mar 7, 2025

Copy link
Copy Markdown
Contributor

@stedolan Do we forbid to put weak pointers in weak pointers? The infamous Weak.get_copy functions copies the value using Field, so we would copy the locked value in that case.

@stedolan

Copy link
Copy Markdown
Contributor Author

The infamous Weak.get_copy functions copies the value using Field, so we would copy the locked value in that case.

That's true, but I don't propose we do anything about it. Putting weak pointers in weak pointers is fine, but Weak.get_copy is completely broken on any value that's not a straightforward immutable block or immediate. It will:

  • create mutable values with unexpected sharing (e.g. causing Dynarray to fail)
  • allow Lazy.t values to be forced twice
  • create objects which are unequal yet have the same identity (per Oo.id)
  • create ephemerons which are never cleaned (since they are not in any todo/live list)
  • create exceptions which cannot be caught (except with wildcards)
  • create continuations which segfault on use
  • do something remarkable to a Domain.t

This patch adds "potentially create a forever-locked Weak.t" to this list of sins, but that seems no worse than the above.

@bobot

bobot commented Mar 13, 2025

Copy link
Copy Markdown
Contributor

Thank you, I knew some of the problems of Weak.get_copy, but not all of them. Can we put this list somewhere in the documentation, in the mli of weak? Or I can update #9990 which is a lot simpler now.

I have reviewed this PR, and think it should be accepted.

@gasche gasche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am approving this PR on behalf of @bobot -- thanks a lot for your time and your review!

This is tricky code and a non-trivial change, so ideally it would be nice to get extra pairs of eyes, but no extra eye has volunteered and @stedolan and @bobot are both experts in this stuff.

(I wanted to say "I'll wait a few days just in case and then go ahead and merge", but I wouldn't want to rely on @stedolan to ping me, we are all going to forget about it, so I propose that someone merges right away once the Changes entry is updated.)

Comment thread Changes
McGilchrist and Fabrice Buoro)

- #13643: Allow values reachable from ephemeron keys to be collected by minor GC
(Stephen Dolan, review by ??)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review by François Bobot

@rbjorklin

Copy link
Copy Markdown

I wanted to say "I'll wait a few days just in case and then go ahead and merge", but I wouldn't want to rely on @stedolan to ping me, we are all going to forget about it, so I propose that someone merges right away once the Changes entry is updated.

Just browsing and came across this by accident. I hope it's okay if I ping @stedolan

@gasche
gasche merged commit 5c709fb into ocaml:trunk Apr 6, 2025
@gasche

gasche commented Apr 6, 2025

Copy link
Copy Markdown
Member

I went ahead, merged the PR, and fixed the Changes via a direct push. Thanks!

@stedolan

stedolan commented Apr 7, 2025

Copy link
Copy Markdown
Contributor Author

Thanks!

tmcgilchrist added a commit to tmcgilchrist/ocaml that referenced this pull request Aug 3, 2026
The runtime has emitted this phase since 5.4 (ocaml#13643), when a minor
collection has to clean locked ephemerons, but the OCaml type had no
constructor for it, so consumers were handed an out-of-range value
and crashed when matching on it.
tmcgilchrist added a commit to tmcgilchrist/ocaml that referenced this pull request Aug 27, 2026
The runtime has emitted this phase since 5.4 (ocaml#13643), when a minor
collection has to clean locked ephemerons, but the OCaml type had no
constructor for it, so consumers were handed an out-of-range value
and crashed when matching on it.
tmcgilchrist added a commit to tmcgilchrist/ocaml that referenced this pull request Sep 1, 2026
The runtime has emitted this phase since 5.4 (ocaml#13643), when a minor
collection has to clean locked ephemerons, but the OCaml type had no
constructor for it, so consumers were handed an out-of-range value
and crashed when matching on it.
smuenzel pushed a commit to smuenzel/ocaml that referenced this pull request Sep 6, 2026
The runtime has emitted this phase since 5.4 (ocaml#13643), when a minor
collection has to clean locked ephemerons, but the OCaml type had no
constructor for it, so consumers were handed an out-of-range value
and crashed when matching on it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gc Performance PR or issues affecting runtime performance of the compiled programs

Projects

None yet

Development

Successfully merging this pull request may close these issues.