Safety
The type system catches many mistakes at compile time before your code runs.
OxCaml Basics
A three-hour technical course for OCaml engineers learning OxCaml's performance-oriented type system.
The type system catches many mistakes at compile time before your code runs.
Algebraic data types, first-class functions, and modules make OCaml expressive and scalable.
Native-code compilation gives OCaml strong and predictable performance.
Low-latency code needs to avoid GC pauses.
Uniform value representation can force inefficient layouts.
OCaml 5 parallelism can introduce subtle data races.
Avoid heap allocation without creating dangling stack pointers.
Remove boxes and pointer hops without representation mismatches.
Use more cores without introducing data races.
Module 1 today
Module 2 today
Module 3 today
Module 01
OxCaml allocation is cheap, but GC is high latency.
stack allocation
ordinary OCaml allocations
the heap fills up
the GC scans and marks live objects
the GC removes unreachable objects
GC pauses can be slow
new in OxCaml
empty allocation stack
separate from the call stack
start a region
allocate into the current region
allocate into the current region
start a new region
allocate into the current region
allocate into the current region
start a new region
allocate into the current region
allocate into the current region
deallocation drops region C
then deallocation drops region B
Stack allocate values with stack_.
OxCaml can often infer stack allocation; stack_ makes it a requirement.
The current allocation region is cleared before the function returns.
A callee cannot secretly keep a stack-allocated value after the caller region is gone.
Expected outcome: the call to sum is rejected.
The integer result can return, but the stack-allocated list cannot.
Expected outcome: returning xs is rejected.
exclave_ builds the returned value in the caller's region.
The caller can use the returned list as a local value.
Normal functions get their own region.
let f x =
...
result
let f x =
let region = start_region () in
...
end_region region;
result
start_region () returns the current allocation stack pointer.
end_region region resets the allocation stack pointer to that saved pointer.
exclave_ uses the parent region.
exclave_ resultlet f x =
exclave_
...
result
let f x =
...
result
Region ends before tail calls.
let rec loop x =
...
loop next_x
let rec loop x =
let region = start_region () in
...
end_region region;
tail call loop next_x
[@nontail] keeps the region open, so stack values can be passed to the call.
let f x =
...
g x [@nontail]
let f x =
let region = start_region () in
...
let result = g x in
end_region region;
result
exclave_exclave_let f x =
let xs = compute_list x in
let n = sum xs in
exclave_
build_result n
let f x =
let region = start_region () in
let xs = compute_list x in
let n = sum xs in
end_region region;
build_result n
If exclave_ is at the beginning, OxCaml starts the region and ends it immediately.
That has the same effect as not starting a region.
x @ globalx @ localLocality answers: how long does this value live?
x : a @ global
x : a @ local
Values have locality, just as they have types. OxCaml tracks the locality of every variable and expression.
f : a @ global -> b @ global
f : a @ global -> b @ local
f : a @ local -> b @ global
f : a @ local -> b @ local
Function signatures specify locality.
type r = { field : a }
x : r @ global
x.field : a @ global
x : r @ local
x.field : a @ local
Projection uses the locality of the record value.
OxCaml has other mode axes besides locality. We will see that later.
stack_ and exclave_stack_ exprThe result is at @ local.
let xs = stack_ [1; 2; 3]
(* xs : int list @ local *)
exclave_ exprAllows @ local return.
let f () : int list @ local =
exclave_
stack_ [1; 2; 3]
exclave_Deletes @ local variables.
let f () =
let y = stack_ [1] in
exclave_
let z = stack_ [2] in
(* z ok *)
(* y bad *)
exclave_exclave_ ends the function's current region. Arguments from the caller can still be used; values allocated before exclave_ cannot.
The with_scratch body can use the temporary state during the call, but cannot retain it, and this is forced by the @ local parameter.
escape s type-checks because s already has global mode.
length s then uses the same value at the more restrictive @ local mode.
@ local data is not always on the allocation stack. Heap data can be used locally too. Stack data cannot be used as global.
The top-level string starts at @ global.
After matching through outer : string list @ local, the extracted inner is only local.
An ordinary list node does not remember that its element was global. Using the outer list at local mode also uses everything reached through it at local mode.
type 'a box = { field : 'a }
r : 'a box @ global
r.field : 'a @ global
r : 'a box @ local
r.field : 'a @ local
Ordinary field projection uses the locality of the record value.
@@ global fieldtype 'a box =
{ field : 'a @@ global }
r : 'a box @ global
r.field : 'a @ global
r : 'a box @ local
r.field : 'a @ global
The field stays global even when the record value is local.
type 'a box =
{ field : 'a @@ global }
let r =
stack_ { field = inner }
r : 'a box @ local
inner : 'a @ global
Even a local record requires the field value at @ global.
A field modality is an exception to ordinary deep mode propagation.
The outer list is local, while the inner string is global.
A local list of global strings: the field modality preserves the global mode.
let id_local_int (x : int @ local) : int @ global =
xNo lifetime-sensitive heap block.
let id_local_string
(x : string @ local) : string @ global =
xRejected: the string may point into a local region.
record r @ global
field r.x @ local
record r @ local
field r.x @ global
Which one is allowed?
record r @ nonportable
field r.x @ portable
record r @ portable
field r.x @ nonportable
Which one is allowed?
record r @ uncontended
field r.x @ contended
record r @ contended
field r.x @ uncontended
Which one is allowed?
local/global controls lifetime and escape.
stack_ forces the next allocation onto the allocation stack.
exclave_ builds a local result in the caller region.
Module 02
int, char, bool, unit, etc. use this shape.
Records, tuples, arrays, etc. use this shape.
let duplicate x = (x, x)
let plain_value_integer =
duplicate 5
let plain_value_tuple =
duplicate (1, 2, 3)
type quote =
{ symbol : string
; price : int
; mutable size : int
}
let plain_value_record =
duplicate { symbol = "ABC"; price = 10; size = 100 }The runtime, GC, and compiler all agree on one value representation. Polymorphic functions are only compiled once.
let plain_value_integer =
5
let plain_value_tuple =
(1, 2, 3)
A tuple is still one OCaml value, but that value word points to a heap block.
type quote =
{ symbol : string
; price : int
; mutable size : int
}
let plain_value_record =
{ symbol = "OxCaml rocks"
; price = 101
; size = 7
}
type tree =
| Zero
| One of tree
| Two of tree * tree
let simple_tree =
Two
(One (One Zero),
Zero)
Constructors with payload allocate blocks; the header tag distinguishes the constructor.
let boxed_numeric_values =
(3.5, 456L)
The tuple points to separate blocks for the boxed numeric payloads.
let unboxed_numeric_values =
#(#3.5, #456L)
The product components are carried directly as raw words.
type unboxed_point =
#{ x : float#
; y : float# }
type sample =
{ label : string
; point : unboxed_point
; count : int
}
let outer_record_with_unboxed_point =
{ label = "p"
; point =
#{ x = #1.0
; y = #2.5 }
; count = 7
}
The new unboxed types do not have the classic one-word shape, so the compiler must know which shape each type has.
For that, OxCaml has kinds: types of types. The kind of a type describes the shape of its values.
The kind value describes the classic OCaml shape. New kinds such as float64 and product kinds describe the new shapes.
Kind annotations are checked, not decorative.
A string is a one-word value, so it cannot be declared with kind float64.
or_null uses null or the payload representationlet ordinary_none_int_option = (None : int option)
let ordinary_some_int_option = Some 7
or_nulllet or_null_null_value = (Null : int or_null)
or_nulllet or_null_this_int = (This 7 : int or_null)
Each diagram is one root value. Some 7 allocates a wrapper block; This 7 is represented like the integer payload.
or_null cannot be nestedNull already uses the all-zero word.
If the inner type is string or_null, it already may be null. The outer or_null would need the same null word for a second missing case.
The layout tracks this: or_null needs a non-null value payload, but string or_null has layout value_or_null.
duplicate is inferred for ordinary one-word values: its type parameter defaults to kind value.
An unboxed point has layout float64 & float64, so it is not accepted as that argument.
The same kind annotations also go on type parameters. Here they say the argument may have the unboxed point layout.
(The result is still a boxed record in this example.)
let%template[@kind k = (value, value & value)] id
: ('a : k). 'a -> 'a =
fun x -> x
let id
: ('a : value). 'a -> 'a =
fun x -> x
let id__'value_value'
: ('a : value & value). 'a -> 'a =
fun x -> x
let a = id 5
let b = (id [@kind value & value]) #(1, 2)
let a = id 5
let b = id__'value_value' #(1, 2)
One source pattern can generate multiple checked definitions with different calling conventions.
OxCaml is a work in progress. Future layout-polymorphic definitions are intended to reduce this template pattern.
One word: immediate data or pointer to a heap block.
Remove selected product or record boxes where the layout is used.
or_nullLike option, but does not allocate.
Module 03
let (left, right) =
Toy.fork_join2
(fun () -> left_work ())
(fun () -> right_work ())
in
combine left right
Children run in parallel; parent resumes after both finish.
let _ =
Toy.spawn (fun () -> worker ())
in
parent_side_effect ()
Spawned worker runs in parallel; parent continues immediately.
let r = ref 0
Toy.fork_join2
(fun _ -> incr r)
(fun _ -> incr r)
let r = ref 0
Toy.fork_join2
(fun _ -> incr r)
(fun _ -> !r)| overlapping operation | read | write |
|---|---|---|
| read | safe | race |
| write | race | race |
Two parallel reads are fine. Two racing writes are not allowed. A racing read and write is not allowed either.
type t = { id : int; mutable hits : int }
Toy.fork_join2
(fun () ->
x.id (* ✓ immutable read *)
x.hits (* ✓ mutable read *)
x.hits <- n (* ✕ mutable write *))
(fun () -> right_work ())
type t = { id : int; mutable hits : int }
Toy.spawn (fun () ->
x.id (* ✓ immutable read *)
x.hits (* ✕ mutable read *)
x.hits <- n (* ✕ mutable write *))
x @ uncontendedx @ sharedx @ contendedContention answers: at this use site, what access do I have to mutable components?
x.idworks even when x is contended
x.hitsneeds x at least shared
x.hits <- x.hits + 1needs x @ uncontended
A direct field operation succeeds or fails according to the variable's contention mode.
x : a @ M
Values have modes, just as they have types. OxCaml tracks the mode of every variable and expression.
f : a @ M -> b @ N
f x : b @ N
Function signatures specify modes.
type r =
{ field : a }
x : r @ M
r.field : a @ M
Projection uses the mode of the record value.
fun x -> expr
Function values also get modes based on what they capture.
Most mode propagation is simple, but functions is where something more complex happens.
x @ sharedx @ contendedThe boundary weakens captured values inside the function; the original variable keeps its mode outside the function.
x @ uncontendedAn ordinary function does not need a parallel-safety promise, so the captured variable keeps ordinary write access.
Key point: a variable's type never changes, but a variable's mode can change when it is captured by a function.
Function argument modes record what level of access the function needs.
xSuppose x is defined outside the List.iter/fork_join2/spawn. Which function calls are allowed?
type t = { id : int; mutable hits : int }
let get_id (x @ contended) = x.id
let get_hits (x @ shared) = x.hits
let bump_hits (x @ uncontended) =
x.hits <- x.hits + 1
List.iter (fun _ ->
ignore (get_id x);
ignore (get_hits x);
bump_hits x) [()]
Toy.fork_join2 (fun () ->
ignore (get_id x);
ignore (get_hits x);
bump_hits x) (fun () -> ())
Toy.spawn (fun () ->
ignore (get_id x);
ignore (get_hits x);
bump_hits x)
| Call | List.iter | fork_join2 | spawn |
|---|---|---|---|
get_id x | |||
get_hits x | |||
bump_hits x |
OxCaml must track portability of functions.
val Toy.fork_join2 :
(unit -> 'a) @ shareable ->
(unit -> 'b) @ shareable ->
'a * 'b
val Toy.spawn :
(unit -> unit) @ portable ->
unit
portableshareablenonportablePortability answers: can this value be used in this parallel context?
x becomes @ shared because fork_join2 takes the worker at @ shareable.
val Toy.fork_join2 :
(unit -> 'a) @ shareable ->
(unit -> 'b) @ shareable ->
'a * 'b
x becomes @ contended because spawn takes the worker at @ portable.
val Toy.spawn :
(unit -> unit) @ portable ->
unit
Where can each function be used?
type t = { id : int; mutable hits : int }
let x = { id = 1; hits = 0 }
let id_x () = x.id
let read_x () = x.hits
let write_x v = x.hits <- v
List.iter (fun _ ->
ignore (id_x ());
ignore (read_x ());
write_x 1) [()]
Toy.fork_join2 (fun () ->
ignore (id_x ());
ignore (read_x ());
write_x 1) (fun () -> ())
Toy.spawn (fun () ->
ignore (id_x ());
ignore (read_x ());
write_x 1)
| Function | List.iter | fork_join2 | spawn |
|---|---|---|---|
id_x | |||
read_x | |||
write_x |
bits_to_string now closes over the top-level buf.
Passing that function to fork/join asks whether the function value is @ shareable. It is not, because its hidden capture needs write access.
shareable functionA structured fork/join function may still use captures in shared/read-only mode, but not in uncontended/write-capable mode.
portable functionA spawned function is checked more conservatively: shared/read-only mutable access no longer survives the boundary.
@ uncontended@ shared@ contended@ portable@ shareable@ nonportablecontendedcontendedcontendedportableerrorerrorsharedsharedcontendedportableshareableerroruncontendedsharedcontendedportableshareablenonportable0. Parallel mutation can race.
1. Simple model: functions passed to parallel work are checked.
2. Reads and writes are checked using the contention axis.
3. Functions are checked using the portability axis.
Safe high-performance programming.
Avoid heap allocation without creating dangling stack pointers.
Remove boxes and pointer hops without representation mismatches.
Use more cores without introducing data races.