ocaml-boxroot-sys 0.4.0

Boxroot for OCaml: fast movable GC roots (C API)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
# Boxroot: fast movable roots for the OCaml-C and OCaml-Rust interfaces

## New in the latest version

See [Changes.md](Changes.md).

## Description

This repository hosts a new root-registration API for the OCaml
garbage collector, compatible with OCaml 4 and OCaml 5. The new kind
of roots are called `boxroot` (boxed roots).

The official root-registration APIs of OCaml let users decide which
existing parts of memory should be considered as new roots by the
runtime. With boxroots, it is our allocator, not the user, that
decides where these roots are placed in memory. This extra flexibility
allows for a more efficient implementation.

We provide an implementation of this idea as a standalone C library
([boxroot/](boxroot/) in this repository), as a custom allocator using
OCaml's GC scanning hooks. Our prototype already shows promising
performance in benchmarks.

In addition to better performance, movable roots fit a common use-case
where Ocaml values are placed inside malloc'ed blocks and then
registered as global roots, for instance for insertion in C library
data structures. This pattern appears to be common. Our original
motivation for prototyping boxroots generalises that: it is to propose
an idiomatic manipulation of OCaml roots from Rust, similar to
`Box<T>` pointers.

We provide raw Rust bindings
([rust/ocaml-boxroot/](rust/ocaml-boxroot/) in this repository). These
bindings should not be used directly; they are used by libraries such
as [`ocaml-interop`](https://github.com/tizoc/ocaml-interop/) to
provide safe abstractions for the OCaml GC (e.g. as a smart pointer).

We also provide a smart pointer `Boxroot` for C++ based on
`unique_ptr` ([boxroot/cpp/](boxroot/cpp/) in this repository).

## Design

Functions to acquire, read, release and modify a `boxroot` are
provided as follows.

```c
boxroot boxroot_create(value);
value boxroot_get(boxroot);
value const * boxroot_get_ref(boxroot);
void boxroot_delete(boxroot);
int boxroot_modify(boxroot *, value);
```

These functions operate in constant time. (This can be compared to the
probabilistic logarithmic time offered by global roots.)

See [boxroot/boxroot.h](boxroot/boxroot.h) for API documentation.

## Benchmarks

To evaluate our experiment, we run various allocation-heavy
benchmarks.

### Implementations

The benchmarks compares various implementation of a cell containing a
single value with the same interface as boxroot (`create`, `get`,
`delete`, `modify`):

- `ocaml`: a pure OCaml implementation where `create` is the identity
  and nothing happens on deletion.
- `ocaml_ref`: a pure OCaml implementation using a mutable record, with
  deletion implemented by assigning `()` using Obj.magic.
- `gc`: a C implementation of the previous, using
  `caml_alloc_small(1,0)`,
- `boxroot`: a `boxroot` disguised as an immediate (reference
  implementation described further below),
- `naive`: like `boxroot`, but without taking advantage of the full
  expressiveness of boxroot (for the local roots benchmark below)
- `global`: a block allocated outside the OCaml heap (disguised as an
  immediate) containing a global root, and
- `generational`: idem, but using a generational
  global root.

Older experiments are not thread-safe (at least not efficiently so):

- `dll_boxroot`: a variant of `boxroot`, but using a simpler
  implementation with doubly-linked lists,
- `rem_boxroot`: a variant of `boxroot`, but using a different
  implementation using OCaml's remembered set,
- `bitmap_boxroot`: a variant of `boxroot` using a bitmap allocator
  inspired by the Hotspot VM implementation of JNI global references.

The various implementations (except the `ocaml` one) have similar
memory representation, some on the OCaml heap and some outside of the
OCaml heap.

By selecting different implementations of Ref, we can evaluate the
overhead of root registration and scanning for various root
implementations, compared to non-rooting OCaml and C implementations,
along with other factors.

We can expect _a priori_ that `ocaml` is always faster, since it has
none of the overheads of other methods. This is meant as a baseline,
but it also shows a limitation of our benchmarks: `boxroot` is
intended for applications where values are _not_ easily reachable from
the OCaml heap and thus none of `ocaml`, `ocaml_ref` and `gc` are
available!

### Benchmark information

The figures below are obtained with OCaml 4.14 and OCaml 5.0
(development version), with CPU AMD Ryzen 5850U.

### Permutations of a list

The small program used in this benchmark computes the set of all
permutations of the list [0; ..; n-1], using a non-determinism monad
represented using (strict) lists. (This is an exponential way to
compute factorial(n) with lots of allocations.)

In our non-determinism monad, each list element goes through the Ref
module that boxes its underlying value, and may be implemented
(through C stubs) as an abstract block (not followed by the GC) whose
value is registered as a GC root.

This benchmark creates a lot of roots alive at the same time.

```
$ echo OCaml `ocamlc --version` && make run-perm_count TEST_MORE=2
OCaml 4.14.0
Benchmark: perm_count
---
boxroot: 1.71s
gc: 1.49s
ocaml: 1.20s
generational: 5.90s
ocaml_ref: 1.47s
dll_boxroot: 2.13s
rem_boxroot: 1.97s
global: 48.19s
```

This benchmark allocates 1860 boxroot pools, and performs 1077 minor
and 18 major collections. Roughly 22M boxroots are allocated.

We see that global roots add a large overhead, which is reduced by
using generational global roots. Boxroots outperform generational
global roots, and give slightly slower time than equivalent pure-GC
implementations (`ocaml_ref` and `gc`).

```
$ echo OCaml `ocamlc --version` && make run-perm_count TEST_MORE=2
OCaml 5.0.0+dev6-2022-07-21
Benchmark: perm_count
---
boxroot: 1.42s
gc: 1.62s
ocaml: 1.25s
generational: 5.43s
ocaml_ref: 1.69s
dll_boxroot: 1.69s
rem_boxroot: 1.55s
global: 49.74s
```

The OCaml 5.0 performance gives us a hint of considerations entering
into boxroot's performance. Here `boxroot` unexpectedly outperforms
`gc` and `ocaml_ref`. Two hypotheses can explain this speedup:
- `boxroot` puts less pressure on the GC. It indeed performs 14%
  fewer minor collections than `gc`.
- `boxroot` has good cache locality during root scanning. Indeed one
  major difference with OCaml 4.14 is the absence of prefetching
  during the major GC. The sensitivity to this effect is confirmed by
  running the benchmark with OCaml 4.12.0, which also lacks
  prefetching and shows `boxroot` also outperforming its pure-GC
  counterparts.

### Synthetic benchmark

In this benchmark, we allocate and deallocate values and roots
according to probabilities determined by parameters.

* `N=8`: log_2 of the number of minor generations
* `SMALL_ROOTS=10_000`: the number of small roots allocated (in the
  minor heap) per minor collection,
* `LARGE_ROOTS=20`: the number of large roots allocated (in the major
  heap) per minor collection,
* `SMALL_ROOT_PROMOTION_RATE=0.2`: the survival rate for small roots
  allocated in the current minor heap,
* `LARGE_ROOT_PROMOTION_RATE=1`: the survival rate for large roots
  allocated in the current minor heap,
* `ROOT_SURVIVAL_RATE=0.99`: the survival rate for roots that survived
  a first minor collection,
* `GC_PROMOTION_RATE=0.1`: promotion rate of GC-tracked values,
* `GC_SURVIVAL_RATE=0.5`: survival rate of GC-tracked values.

These settings favour the creation of a lot of roots, most of which
are short-lived. Roots that survive are few, but they are very
long-lived.

```
$ echo OCaml `ocamlc --version` && make run-synthetic TEST_MORE=2
OCaml 4.14.0
Benchmark: synthetic
---
boxroot: 6.19s
gc: 7.15s
ocaml: 6.21s
generational: 10.83s
ocaml_ref: 7.03s
dll_boxroot: 6.68s
rem_boxroot: 6.41s
global: 15.89s
```

This benchmark allocates 772 boxroot pools, and performs 2,619 minor
and 141 major collections. Roughly 16M roots are allocated.

`boxroot` again performs better than other root implementations, but
it is unexpected again that it outperforms `ocaml`, `ocaml_ref` and
`gc`. This is not well-understood.
- `gc` does actually fewer minor and major collection.
- Running with or without a prefetching GC shows comparable
  performance between `boxroot` and `ocaml`.

```
$ echo OCaml `ocamlc --version` && make run-synthetic TEST_MORE=2
OCaml 5.0.0+dev6-2022-07-21
Benchmark: synthetic
---
boxroot: 2.86s
gc: 2.98s
ocaml: 2.82s
generational: 4.80s
ocaml_ref: 2.99s
dll_boxroot: 3.04s
rem_boxroot: 3.01s
global: 6.08s
```

In multicore, this benchmark allocates 408 boxroot pools, and performs
2,615 minor and 55 major collections. 8.7M roots are allocated.
Therefore absolute values are not comparable between major OCaml
versions. The relative results are similar between OCaml 4.14 and
OCaml 5.0.

### Globroot benchmark

This benchmark is adapted from the OCaml testsuite. It exercises the
case where there are about 1024 concurrently-live roots, but only a
couple of young roots are created between two minor collections.

This benchmark tests the case where there are few concurrently-live
roots and little root creation and modification between two
collections. This benchmark does not perform any OCaml computations or
allocations (it forces collections to occur very often despite low GC
work). So the cost of root handling is magnified, it would normally be
amortized by OCaml computations.

```
$ echo OCaml `ocamlc --version` && make run-globroots TEST_MORE=2
OCaml 4.14.0
---
boxroot: 1.03s
gc: 1.35s
ocaml: 0.89s
generational: 1.14s
ocaml_ref: 1.27s
dll_boxroot: 0.99s
rem_boxroot: 0.95s
global: 1.24s
```

In this benchmark, there are about 67000 minor collections and 40000
major collections. 217k boxroots are allocated.

Since there are few root creations between collections, list-based and
remembered-set-based implementations are expected to perform well
(their scanning is quick). On the other hand, `boxroot` has to scan on
the order of a full memory pool at every minor collection even if
there are only a few young roots, for a pool size chosen large (16KB).
There used to be a noticeable overhead in this benchmark, but it has
been reduced with optimizations brought to scanning during minor
collection.

```
$ echo OCaml `ocamlc --version` && make run-globroots TEST_MORE=2
OCaml 5.0.0+dev6-2022-07-21
Benchmark: globroots
---
boxroot: 0.82s
gc: 1.01s
ocaml: 0.72s
generational: 0.92s
ocaml_ref: 1.02s
dll_boxroot: 0.84s
rem_boxroot: 0.78s
global: 1.34s
```


![Global roots benchmarks (OCaml 4.14)](global.svg)

![Global roots benchmarks (OCaml 5.0)](global5.svg)

### Local roots benchmark

We designed this benchmark to test the idea of replacing local
roots altogether by boxroots.

Currently, OCaml FFI code uses a "callee-roots" discipline where each
function has to locally "root" each OCaml value received as argument
or used as a temporary paramter, using the efficient `CAMLparam`,
`CAMLlocal`, `CAMLreturn` macros. These macros manage a shadow stack
containing pointers to the live roots.

Boxroots suggest a "caller-root" approach where callers would
package their OCaml values in boxroots, whose ownership is
passed to the callee. Creating boxroots is slower than
registering local roots, but the caller-root discipline can
avoid re-rooting each value when moving up and down the call
chain, so it is expected to have a performance advantage for
deep call chains.

This benchmark performs a (recursive) fixpoint computation on
OCaml floating-point value from C, with a parameter N that
decides the number of fixpoint iterations necessary, and thus
the length of the C-side call chain.

The local-roots version is as follows:

```c
int compare_val(value x, value y);

value local_fixpoint(value f, value x)
{
  CAMLparam2(f, x);
  CAMLlocal1(y);
  y = caml_callback(f,x);
  if (compare_val(x, y)) {
    CAMLreturn(y);
  } else {
    CAMLreturn(local_fixpoint(f, y));
  }
}
```
where `compare_val` compares the values of `x` and `y`, but introduces
local roots in order to simulate a more complex operation.

The boxroot version is as follows:

```c
value boxroot_fixpoint(value f, value x)
{
  boxroot f_root = boxroot_create(f);
  boxroot x_root = boxroot_create(x);
  boxroot y = boxroot_fixpoint_rooted(boxroot_get_ref(f), x_root);
  value v = boxroot_get(y);
  boxroot_delete(y);
  boxroot_delete(f_root);
  return v;
}

int compare_refs(value const *x, value const *y);

boxroot boxroot_fixpoint_rooted(value const *f, boxroot x)
{
  boxroot y = boxroot_create(caml_callback(*f, boxroot_get(x)));
  if (compare_refs(boxroot_get_ref(x), boxroot_get_ref(y))) {
    boxroot_delete(x);
    return y;
  } else {
    return boxroot_fixpoint_rooted(f, y);
  }
}
```
where `compare_refs` does the same work as `compare_val` but expects
its values already rooted.

The work is done by `boxroot_fixpoint_rooted`, but we need a
`boxroot_fixpoint` wrapper to go from the callee-roots convention
expected by OCaml `external` declarations to a caller-root convention.
(This wrapper also adds some overhead for small call depths.)

The `naive` test uses boxroots in a callee-roots discipline.


```
$ echo OCaml `ocamlc --version` && make run-local_roots TEST_MORE=2
OCaml 4.14.0
```

![Local roots benchmarks (OCaml 4.14)](local.svg)

We see that, in this test, despite the up-front cost of wrapping the
function, `boxroot`s are equivalent to or outperform OCaml's local
roots. More precisely, `boxroots` are slightly more expensive than
local roots when following the same callee-roots discipline, and the
caller-roots discipline offers huge saves in this benchmark. The saves
from the caller-roots discipline come from:
- introducing fewer roots,
- enabling recursion to be done via a tail call,
- enabling better code generation after inlining.

The results greatly depends on the test program and programming style.
For instance we did not take into account local roots optimisations
that fall outside of the documented syntactic rules and rely on expert
knowledge of their implementation, since one of our goal is to propose
an interface that can easily be made safe in Rust.

Our conclusions:
- Using boxroots is competitive with local roots.
- It can be beneficial if one leverages the added flexibility of
  boxroots.
- There could be specific scenarios where it is much more beneficial,
  for instance when traversing large OCaml structures from a foreign
  language, with many function calls.

Furthermore, we envision that with support from the OCaml compiler for
the caller-roots discipline, the wrapping responsible for initial
overhead could be made unnecessary.

```
$ echo OCaml `ocamlc --version` && make run-local_roots TEST_MORE=2
OCaml 5.0.0+dev6-2022-07-21
```

![Local roots benchmarks (OCaml 5.0)](local5.svg)

In multicore, we observe similar results. However, the overhead of C
calls appears to be higher, and (generational) global roots are much
more expensive since they are protected by a mutex.

We have measured the performance of two alternative implementations:
- _thread-unsafe_: assume that there is only one thread that never
  releases the domain lock, and thus avoid related checks in
  `boxroot_create` and `boxroot_delete`.
- _force remote_: the opposite, perform all deallocations as if they
  were done on a different domain, using the lock-free atomic
  deallocation path.

![Local roots benchmarks (impact of multicore support)](local5-2.svg)

Our thread-safe implementation of Boxroot for OCaml multicore is
slightly slower than a version that does not perform checks necessary
for thread-safety. The difference is likely lesser in other kinds of
situations where more time is spent in cache misses.

The implementation where all deallocations are done remotely is only
slightly slower than Boxroot (although with a much higher pool count
currently, due to the fact that remote deallocations are delayed until
the next garbage collection). However this single-threaded benchmark
does not let us see the costs of cache effects in realistic
multithreaded scenarios (cache misses and contention).

Our conclusions:
- The overhead of multithreading support is low enough to propose
  Boxroot as an all-purpose rooting machanism.
- The performance of cross-thread deallocation is likely very good,
  but we need better benchmarks to measure this.

## Implementation

We implemented a custom allocator that manages fairly standard
freelist-based memory pools, but we arrange to scan these pools
efficiently. In standard fashion, the pools are aligned in such a way
that the most significant bits can be used to identify the pool from
the address of their members. Since elements of the freelist are
guaranteed to point only inside the memory pool, and non-immediate
OCaml values are guaranteed to point only outside of the memory pool,
we can identify allocated slots as follows:

```
allocated(slot, pool) ≝ (pool != (slot & ~(1<<N - 2)))
```

N is a parameter determining the size of the pools. The bitmask is
chosen to preserve the least significant bit, so that immediate OCaml
values (those with lsb set) are correctly classified.

Scanning is set up by registering a root-scanning hook with the OCaml
GC, and done by traversing the pools linearly. An early-exit
optimisation when all roots have been found ensures that programs that
use few roots throughout the life of the program only pay for what
they use.

### Generational optimisation

The memory pools are managed in several rings, according to their
*class*. The class distinguishes pools according to OCaml generations,
as well as pools that are free (which need not be scanned). A pool is
*young* if it is allowed to contain pointers to the minor heap. During
minor collection, we only need to scan young pools. At the end of the
minor collection, the young pools, now guaranteed to no longer point
to any young value, are promoted into *old* pools.

We unconditionally allocate roots in young pools, to avoid testing at
allocation-time whether their initial value is young or old. This test
is better amortized if done from the collector for two reasons: in
some situations there may be many more roots allocated than living
through collections, and there is a way to make this test very
efficient when done in a tight loop. In addition, we want to inline
the fast paths of `boxroot_create` and `boxroot_delete`, so we gain in
reducing code size and avoiding branches that are not statically
predictible.

The rings are managed in such a manner that pools that are less than
half-full are rotated to the start of the ring. This ensures that it
is easy to find a pool for allocation. When the current pool is full,
if no young pool is available then we demote the first old pool into
a young pool, if it is less than half-full. (This pool contains major
roots, but it is harmless to scan them during minor collection.)
Otherwise we prefer to allocate a new pool.

Care is taken so that programs that do not allocate any root do not
pay any of the cost.

### Multicore implementation

In OCaml multicore, each domain has its own set of pool rings. Each
allocation is performed in the domain-local pools. As for
deallocations, each one is classified into: _local_, _remote domain_,
_purely remote_. (Improvements have been upstreamed for the release of
OCaml 5.0 to let us perform this classification efficiently.)

- Local deallocations are done on the same domain and while holding
  the domain lock. They are performed immediately without any
  synchronisation necessary.
- Remote domain deallocations are done from a different domain than
  the one that has allocated the boxroot. The typical use-case is
  sending OCaml values between threads, or foreign data structures
  containing such values. It is done while holding some domain lock,
  and thus we know that no interference with scanning is possible. It
  is pushed on an remote free list using an atomic exchange and an
  atomic increment. The remote free list is pushed back on top of the
  main free list at the start of scanning, which takes place during a
  stop-the-world section, when no other remote deallocation can take
  place.
- Purely remote deallocations are done without holding the domain
  lock. The typical use-case is the clean-up of foreign data
  structures that would store OCaml values while releasing the domain
  lock, which is rarer, so its performance is secondary. To avoid
  interference with scanning without making the latter very slow, each
  pool has a mutex that needs to be locked during purely remote
  deallocation. This mutex is also locked before scanning the pool. In
  all other aspects the purely remote deallocation is treated like a
  remote domain deallocation.

## Limitations

* This library has only been tested on Linux 64-bit, though it would
  be easy to port it to other platforms if it does not work right
  away. Please get in touch for any portability requirement.

* Due to limitations of the GC hook interface, no work has been done
  to scan roots incrementally at the start of major GC. Holding a
  (very!) large number of roots at the same time can negatively affect
  latency at the beginning of major GC cycles.