OCaml with Jane Street extensions is available from our public opam repo. Only a slice of the features described in this series are currently implemented.


Rust, OCaml, and Resource Management

Coming from OCaml, the Rust programming language has many appealing features. Rust’s system for tracking lifetime and ownership allows users to safely express patterns that are awkward in OCaml, such as:

  • Stack-allocated values and custom allocation schemes.
  • Managed resources that can’t be (easily) garbage collected, e.g. file descriptors or GPU memory.
  • Mutable data structures in the presence of concurrency.

On the other hand, Rust’s approach comes with some trade-offs. Eschewing garbage collection requires careful consideration of lifetime and ownership throughout a codebase. Emphasizing lifetime-polymorphism can also make type inference untenable, a design choice that wouldn’t fit OCaml.

At Jane Street, we’ve been working on extending OCaml to better support these use cases, without giving up the principles that make OCaml a convenient and flexible language.

To do so, we’re introducing a system of modes, which track properties like the locality and uniqueness of OCaml values. Modes allow the compiler to emit better, lower-allocation code, empower users to write safer APIs, and with the advent of multicore, statically guarantee data race freedom—all in a lightweight way that only affects those in need.

Stack Allocation

The OCaml compiler does not statically track lifetimes. Instead, it relies on a garbage collector to figure out a suitable lifespan for each value at runtime. Values are collected only after they become unreferenced, so OCaml programs are memory-safe.

To a first approximation, this model requires allocating all values on the heap. Fortunately, OCaml’s generational GC can efficiently handle short-lived values—minor-heap allocation simply advances a ring buffer.

Diagram of minor heap allocation.


However, placing everything on the heap is still a pessimistic approach. Where possible, using a specialized allocator could improve performance. For example, the minor heap is typically larger than cache, so future allocations are likely to evict live values. Stack allocation would immediately re-use freed space, eliminating this concern.

Diagram of stack allocation.


Providing an alternative to heap allocation would also have other benefits:

  • Every minor heap allocation brings us closer to the next minor collection cycle. A minor collection incurs some fixed overhead, but more importantly, frequent collection causes more values to be moved to the major heap. Promoted values become much costlier to collect later on.

  • At Jane Street, we often write “zero-allocation” code, which must never trigger a GC cycle. A stack allocator would make it much easier to write programs that do not touch the heap.

When such performance concerns are relevant, one should arguably be using a language based on explicit memory management, like Rust. However, garbage collection is genuinely useful; explicit management is a burden on users. Ideally, a language could provide a spectrum of allocation strategies freely interoperable within a single application. With modes, users can write OCaml with all the usual GC guarantees—but when performance is paramount, opt into the consideration of lifetimes, ownership, and concurrency.

Local Variables

In OCaml, it turns out that many short-lived values can be stack-allocated. To safely refer to such values, we introduce local variables.

Determining whether a variable is local involves checking a certain condition on its lifetime. Consider the following function:

let is_int str =
    let opt = Int.of_string_opt str in
    match opt with
    | Some _ -> true
    | None -> false
;;
val is_int : string -> bool

Naively, this function incurs a heap allocation. The compiler does not know the lifetime of opt—our function could return it, or even store it in a global variable. Because opt could escape this function, the value referenced by opt may need to live forever. Therefore, it must be heap-allocated.

Diagram of `opt` placed on the heap.


As the programmer, however, we can deduce that a shorter lifetime suffices. In fact, opt only needs to live until we match on it. When is_int returns, opt is no longer accessible, so it could have safely been allocated in stack memory local to is_int.

Diagram of `opt` placed on the stack.


Specifically, opt is local because its lifetime does not exceed its enclosing stack frame, which we call its region. At runtime, entering is_int begins a region by saving the current stack pointer; exiting ends the region and reclaims stack-allocated memory. Since opt is only accessible within this region, it may safely be allocated in the corresponding stack frame.

Note that a stack-allocated value is not necessarily stored on the control flow stack, as seen in languages that support alloca(). In this example, we request space from a stack-based allocator backed by entirely unrelated memory.

The Locality Mode

So, local variables are those that do not escape their region. To formalize this constraint in a manner the compiler can check, we introduce modes.

  • By default, variables have the global mode. A global variable has the capability to escape any region, so always references the heap.

  • Variables with the new local mode cannot escape their enclosing region, so may refer to the stack.

A mode is attached to a variable upon declaration, either in a let binding or in a function parameter. In both cases, the compiler will check that the value does not escape its region.

let foo (local x) =
    let local y = 0 in
    x, y
;;
3 | x, y
    ^
Error: this value escapes its region.

A local parameter represents a promise by the callee: the function will not store a reference to the value anywhere that could be accessed after the function returns. Intuitively, it’s safe to pass a stack-allocated value to a function if we know the value’s lifetime will not be extended.

let is_empty (local str) =
    String.length str = 0
;;
val is_empty : string @ local -> bool

Here, the syntax string @ local denotes that is_empty takes its parameter “at” the local mode.

Even without explicit mode annotations, the compiler can statically determine which variables may escape their enclosing region. Such variables are assigned the global mode; all others are automatically inferred to be local. At this point, the compiler may construct values bound to local variables using stack allocation.

Local Returns

Returning a local value from a function should appear contradictory, since a function’s result has clearly escaped its region. On the other hand, if functions can only return globals, constructing fully stack-allocated values becomes difficult—they can only be built up from literals. The solution:

let local_list () =
    exclave [1; 2; 3]
;;
val local_list : unit -> int list @ local

The exclave keyword ends the current region and executes the given expression in the enclosing region. The caller receives a local variable prohibited from escaping the caller’s region. Therefore, it’s safe to allocate that value on the caller’s stack frame—the difference is simply which region the value lives in.

let bar () =
    let list = local_list () in
    list
;;
3 | list
    ^^^^
Error: this value escapes its region.

Local-returning functions are the primary method of creating stack-allocated values, as they can programmatically build up local data structures. This mechanism also allows functions to return their local parameters.

Lastly, recall that the ‘locals’ stack is distinct from the control flow stack, making this behavior easy to implement.

Locality in APIs

Locality doesn’t only facilitate stack allocation—it also lets us design safer APIs. The following code exhibits a common pattern for resource management:

Core_unix.with_file "file" ~mode:[ O_RDONLY ] ~f:(fun fd -> (* ... *))

Here, a file descriptor is opened, passed to a lambda function, and closed after the function returns. This API lets users eschew manually closing the file descriptor. However, there’s no guarantee that the descriptor is not used after it’s closed.

let stash = ref 0 in
Core_unix.with_file "file" ~mode:[ O_RDONLY ] ~f:(fun fd -> stash := fd);
Core_unix.close !stash
Exception: Unix.Unix_error(Unix.EBADF, "close", ...)

Of course, this design can be improved by making fd a local parameter. After changing the signature of with_file to the following…

val with_file : string -> mode:open_flag list -> f:(File_descr.t @ local -> 'a) -> 'a

…the callback must promise not to stash away the file descriptor. Therefore, we know the file won’t be used after the callback returns.

In this example, we’re using modes to require a promise from the caller. This usage might feel similar to local returns, and for good reason. Formally, when a parameter is used contravariantly, its mode represents a restriction on the callee, but when used covariantly (as seen here), it instead represents a restriction on the caller.

Modes vs. Types

Above, we declared a local integer x using the syntax let local. Notably, we didn’t simply add a type annotation—the local mode does not operate on types. In fact, the mode of x is entirely separate from the type of x.

Types describe data structures, that is, how to build up and take apart values. On the other hand, a mode encodes a property independent of data layout, so may be attached to a variable of any type. To illustrate this behavior, type annotations specify a variable at a mode using the syntax type @ mode.

let local x = 0
let local y = "string"
let local z = [0.0; 1.0]
val x : int @ local
val y : string @ local
val z : float list @ local

In the case of locality, the salient property is whether a value may escape its region. Variables with the global mode can escape any region, so global values are correspondingly heap allocated. Conversely, the local mode restricts a variable to its region; a local value may be stack allocated.

Locality vs. Lifetimes

Encoding locality with a mode has some advantages compared to Rust’s type-centric approach. In Rust, reference types are parameterized over specific regions represented by lifetime variables. This design is more expressive than locality, which only distinguishes values that may escape all regions from those that cannot escape any.

On the other hand, lifetime variables are a source of pervasive complexity. When references are inherently polymorphic, essentially all functions become lifetime-polymorphic as well. For example, whenever a reference lacks a lifetime annotation, an implicit lifetime variable appears:

fn print_string(s: &str);

// Is equivalent to...

fn print_string<'a>(s: &'a str);

Since Rust supports first-class functions, the result is that higher-order functions require higher-order polymorphism, for which type inference is undecidable in general.

OCaml’s modes do not affect type inference—they preserve the types of existing code, so users truly don’t need to consider modes they aren’t actively using. In OCaml, type inference, higher-order functions, and garbage collection are all important parts of the development workflow, so we consider the local mode to be a good fit.

Modes are Deep

Above, we noted that a mode describes a property independent of data layout. Such properties are deep, as opposed to the shallow layout encoded by a type. To understand this distinction, consider the following type:

type 'a list =
    | Empty
    | More of 'a * 'a list

Destructuring a value of type 'a list produces two possible outcomes: either the empty list, or a pair of a value and another list of arbitrary shape. Hence, the type only describes the value’s top-level structure.

let process list =
    match list with
    | Empty -> (* ... *)
    | More (head, remaining) -> (* ... *)
;;

Conversely, destructuring a global variable of type 'a list produces either an empty list, or a pair of a global value and another global list. That is, if the root node of the list may escape its region, the subsequent nodes clearly can too—so the entire list must be heap-allocated.

Diagram of a list placed on the heap.


The same logic applies to the local case: destructuring a local list produces a local value and another local list. It is possible to create a local list consisting entirely of stack allocations, so we must ensure that the contents of a local list also do not escape.

Diagram of a list placed on the stack.


Deepness enables the compiler to validate usage of local data structures:

let head (local list) =
    match list with
    | Empty -> None
    | More (head, _) -> Some head
;;
4 | | More (v, _) -> Some head
                          ^^^^
Error: This value escapes its region

If locality didn’t exhibit “deepness,” it wouldn’t be very useful—we could stack allocate the root node of a list, but we’d have no way to express that further nodes may also be stack-allocated.

Sub-Modes

Given deepness, locality might appear to be an “all or nothing” choice—so far, we’ve allocated our data structures either entirely on the stack or entirely on the heap. To break this dichotomy, we will explore another important property of modes: each mode axis admits a natural sub-typing relation.

In the case of locality, it’s intuitively safe to use a global variable as if it were local. For example, a function expecting a local parameter promises equivalent behavior whether or not the parameter actually lives on the stack. Therefore, we say global is a sub-mode of local and allow global values to be used at the local mode.

let localize x = exclave x
val localize : 'a -> 'a @ local

It is safe for a local value to reference a global, but not vice versa. At runtime, this means we can create pointers from the stack to the heap, but not from the heap to the stack. For example, we can create a local, fully stack-allocated list whose nodes refer to heap-allocated values.

let rec localize list = exclave
    match list with
    | Empty -> Empty
    | More (head, remaining) -> More (head, localize remaining)
;;
val localize : 'a list -> 'a list @ local
Diagram of a list on the stack referencing values on the heap.


We could also create a local list where only the first node is stack-allocated—say, if we locally append to a global list.

let local_cons (local head) remaining = exclave
    More (head, remaining)
;;
val local_cons : 'a @ local -> 'a list -> 'a list @ local
Diagram of a list partially on the stack and the heap.


What we cannot create is a global list containing a stack-allocated node. Again, modes are deep, so any global list must have only captured globals. This preserves the invariant that whenever a node is heap-allocated, all nodes reachable from it are also heap-allocated.

More rigorously, we could say that as we traverse a value, the current mode monotonically increases with depth. This restriction should also make intuitive sense, since any list without this property contains a pointer from the heap to the stack. Such a pointer is potential use-after-free bug—the heap node may still be reachable after the stack frame has been freed.

Diagram of an impossible list that moves from the stack to the heap and back.


The above layout can be represented using Rust lifetimes, which support subtyping. However, safely manipulating such data structures requires significantly more reasoning on the programmer’s part. Lifetime variables refer to arbitrary regions, so subtyping relationships must be explicitly specified. Locality offers a compromise: considering just one lifetime—the current region—makes efficient stack allocation easy to use in many practical scenarios. Values with other lifetimes are still managed by the garbage collector.

Global Record Fields

Because modes are deep, a local record always contains local values. Such fields may be stack allocated, so must be prohibited from escaping the current region. However, since global is a sub-mode of local, inner values may also be heap allocated—and sometimes the programmer knows they always will be. In this case, locality is unnecessarily restrictive.

Therefore, we also support annotating record fields with an explicit global mode. The compiler forbids initializing a global field using a local variable. Global fields are hence allowed to escape their region:

type 'a box = { global x : 'a }

let unwrap (local box) =
    box.x
;;
val unwrap : 'a box @ local -> 'a

Explicitly mutable record fields are automatically considered global. If this were not the case, a function could leak a local variable by storing it within a local parameter, violating region safety. For example:

type 'a box = { mutable x : 'a option }

let clear (local box) =
    let local y = None in
    box.x <- y
;;
5 | box.x <- y
             ^
Error: this value escapes its region.

Locality in Practice

At Jane Street, we’ve been using locality in production for some time. Developers who work on performance sensitive systems use locals daily, and those who don’t are largely unfamiliar with the feature—which means we’ve successfully limited the costs to the users who care. Therefore, we consider locality’s expressivity and performance benefits well worth the additional language complexity.

Building on locality’s success, the compilers team is now implementing additional modes for describing ownership constraints. In part two, we will explore new mode axes representing uniqueness and linearity.