Thomas Gazagnaire

Thomas Gazagnaire

Building Functional Systems from Cloud to Orbit. thomas@gazagnaire.org

Reusing Buffers in Multicore OCaml

2026-07-24

OCaml is a pragmatic functional language. You can do very advanced things with its type system (like an entire roguelike), and you can also hide the traditional buffer management techniques of C behind a nice abstract interface. One of those techniques stopped being safe when OCaml became multicore; I've hit it in ocaml-wire last week. ocaml-wire is the codec layer of Parsimoni's space protocol stack; its decode path runs for every packet, so it should be safe and fast (and ideally, work with multiple domains!).

Validators

ocaml-wire implements EverParse's model of binary formats. EverParse is Project Everest's verified parser generator: a format description carries its own validity constraints, and the generated validator rejects malformed input before any application code sees it. The library keeps that shape in OCaml, and can emit .3d files when formally verified C parsers are needed.

For instance, take a record of three 16-bit fields x, y and z whose last field must be the sum of the first two. EverParse calls this kind of constraint a where clause:

let f_x = Field.v "X" uint16be
let f_y = Field.v "Y" uint16be
let f_z = Field.v "Z" uint16be

let codec =
  Codec.v "Record"
    ~where:Expr.(Field.ref f_x + Field.ref f_y = Field.ref f_z)
    (fun x y z -> { x; y; z })
    [
      (f_x $ fun r -> r.x);
      (f_y $ fun r -> r.y);
      (f_z $ fun r -> r.z);
    ]

A hand-written version of the same validator shows what runs underneath: the decoder pulls each field into a slot, then evaluates the constraint over the slots:

let valid_naive buf off =
  let slots = Array.make 3 0 in
  for i = 0 to 2 do
    slots.(i) <- Bytes.get_uint16_be buf (off + (2 * i))
  done;
  slots.(0) + slots.(1) = slots.(2)

It allocates its slots on every call. A good way to see it is by using memtrace, OCaml's allocation profiler. The program opts in with one call at startup, Memtrace.trace_if_requested (), and setting MEMTRACE=naive.ctf in the environment turns sampling on, at a default rate of one sample per million allocated words. Running the validator ten million times under the profiler gives:

$ memtrace_hotspots naive.ctf
   36 samples of  0.3 GB allocations

 0.3 GB (100.0%) at Dune__exe__Records.valid_naive (records.ml:8:14-28)

Four words per call (the three slots plus the array header) over ten million calls, at eight bytes per word, is the 0.3 GB; the sampling rate reduces it to a few dozen samples, each with a source location.

The usual fix is to hoist the scratch to the toplevel and reuse it:

let slots = Array.make 3 0

let valid_scratch buf off =
  for i = 0 to 2 do
    slots.(i) <- Bytes.get_uint16_be buf (off + (2 * i))
  done;
  slots.(0) + slots.(1) = slots.(2)

The same trace now reports 0 samples of 0 B allocations. ocaml-wire's validators use this layout: decoded fields land in slots owned by the codec value, allocated once when the codec is built.

Domains

OCaml 5 gave the language real parallelism: spawn a domain and two pieces of OCaml run on two cores at once. It also turned every persistent scratch into shared mutable state, because a codec value shared between domains shares its slots too. Run valid_scratch from two domains, one validating a well-formed record and one a malformed record, a million times each, and each domain overwrites the other's slots mid-validation:

115726 wrong decodes out of 2000000

No exception is raised; the calls return wrong verdicts. For ocaml-wire it means that valid packets can be silently dropped (in a TCP stack that runs one connection per core, the dropped segment stalls the connection), so adding cores decreases throughput. Fortunately, OCaml ships a ThreadSanitizer build, available as an opam switch with ocaml-option-tsan (read more on the Tarides blog). It instruments every memory access, and the first run reports the racing pair, both stacks pointing at the validator:

WARNING: ThreadSanitizer: data race (pid=90385)
  Write of size 8 at 0x00010a5ff720 by thread T4 (mutexes: write M0):
    #0 camlRecords$valid_scratch_432 (race_tsan:arm64+0x10000465c)
  Previous write of size 8 at 0x00010a5ff720 by thread T2 (mutexes: write M1):
    #0 camlRecords$valid_scratch_432 (race_tsan:arm64+0x10000465c)

To fix this, the OCaml standard library has several tools. Here I picked Domain.DLS, domain-local storage: a key is created once, and each domain gets its own value, initialised on first use. With it the scratch moves from one per program to one per domain:

let slots_key = Domain.DLS.new_key (fun () -> Array.make 3 0)

let valid_dls buf off =
  let slots = Domain.DLS.get slots_key in
  for i = 0 to 2 do
    slots.(i) <- Bytes.get_uint16_be buf (off + (2 * i))
  done;
  slots.(0) + slots.(1) = slots.(2)

The race test now reports zero wrong decodes and ThreadSanitizer is silent. The per-call allocation count stays at zero, since the scratch is created once per domain and Domain.DLS.get is a lookup. Domain-local is not thread-local: two preemptive systhreads sharing a domain must still not decode one codec value at once. OCaml 5 also ships green-thread machinery through effect handlers, with frameworks like Eio and Miou building fibers on top. Those are safe here: fibers interleave only at effect points, and a decode performs no effects, so a fiber is never descheduled mid-decode.

ocaml-wire had this bug in two places. The decode scratch was shared exactly like valid_scratch above (ocaml-wire#223). Parametric codecs had a second copy: a codec can take an input parameter, for instance a max_len bound on a payload, and the cell holding that parameter also lived in the codec value (ocaml-wire#224). Two domains decoding with different bounds overwrote each other's max_len, so a payload within its own bound could fail the other domain's where check. Both fixes shipped in wire 1.1.0.

References

Related Posts