Pi Day 2026: OCaml vs OxCaml
Mark Elvers
7 min read

Categories

  • pi

Tags

  • tunbury.org

For Pi Day, I have implemented the same algorithm in both OCaml and OxCaml and compared the generated assembly and runtime performance.

OxCaml is a performance-focused extension of OCaml developed at Jane Street. It adds unboxed types, stack allocation, mutable bindings, and compile-time zero-allocation checking to the language. But what do these features actually look like in practice, and do they make a measurable difference?

The algorithm #

This year, I’m going to use the Gauss-Legendre algorithm to compute pi by iteratively refining four variables. Starting from:

a = 1,  b = 1/sqrt(2),  t = 1/4,  p = 1

each iteration updates them:

a' = (a + b) / 2
b' = sqrt(a * b)
t' = t - p * (a - a')^2
p' = 2 * p

and pi is approximated by:

pi ~ (a + b)^2 / (4 * t)

The code is deliberately structured as two functions: a step function that computes one iteration and returns the updated state, and a gauss_legendre function that iterates over step calls. The step function is marked [@inline never] to simulate a realistic cross-module call boundary of the kind that appears in real applications.

OCaml #

(* pi_ocaml.ml - OCaml 5.4.0 *)

let[@inline never] step a b t p =
  let a' = (a +. b) /. 2.0 in
  let b' = sqrt (a *. b) in
  let d = a -. a' in
  let t' = t -. p *. d *. d in
  let p' = 2.0 *. p in
  (a', b', t', p')

let[@inline never] gauss_legendre () =
  let rec loop a b t p = function
    | 0 ->
      let s = a +. b in
      s *. s /. (4.0 *. t)
    | i ->
      let (a', b', t', p') = step a b t p in
      loop a' b' t' p' (i - 1)
  in
  loop 1.0 (1.0 /. sqrt 2.0) 0.25 1.0 25

The step function returns a float * float * float * float tuple. The gauss_legendre function recurses, threading the four values through each iteration.

OxCaml #

(* pi_oxcaml.ml - OxCaml 5.2.0+ox *)

module F = Float_u
open Float_u.O_dot

let[@zero_alloc][@inline never] step a b t p
  : #(float# * float# * float# * float#) =
  let a' = (a +. b) /. #2.0 in
  let b' = F.sqrt (a *. b) in
  let d = a -. a' in
  let t' = t -. p *. d *. d in
  let p' = #2.0 *. p in
  #(a', b', t', p')

let[@zero_alloc][@inline never] gauss_legendre () : float# =
  let mutable a = #1.0 in
  let mutable b = #1.0 /. F.sqrt #2.0 in
  let mutable t = #0.25 in
  let mutable p = #1.0 in
  for _ = 1 to 25 do
    let #(a', b', t', p') = step a b t p in
    a <- a'; b <- b'; t <- t'; p <- p'
  done;
  let s = a +. b in
  s *. s /. (#4.0 *. t)

Three OxCaml features are used here:

unboxed types #

Instead of float, which would be a boxed 64-bit IEEE double, allocated on the heap and subject to garbage collection, a float# stores the value directly in a CPU register. The #(float# * float# * float# * float#) return type is an unboxed tuple: four values returned in four XMM registers, with no heap allocation at all.

Stack-local mutable bindings #

let mutable creates a mutable binding that lives on the stack.

Compile-time allocation checking #

The [@zero_alloc] annotation asks the compiler to prove that the function performs no heap allocation. If any code path allocates, that is, boxing a float or creating a tuple and thereby potentially triggering the GC, the build fails with an error showing exactly where the allocation occurs.

What the compiler generates #

Both versions perform the same arithmetic requiring the same instructions: addsd, divsd, sqrtsd, mulsd, subsd sequence. The difference is what happens when step needs to return its four results to the caller.

OCaml: 104 bytes of heap allocation #

    subq    $104, %r15              ; reserve 104 bytes on the minor heap
    cmpq    (%r14), %r15            ; enough space?
    jb      .L102                   ; if not → trigger GC

    ; Box each float (16 bytes each: 8-byte GC header + 8-byte value)
    movq    $1277, -8(%rbx)         ; GC tag for boxed float
    movsd   %xmm0, (%rbx)           ; store p'
    movq    $1277, -8(%rdi)         ; GC tag
    movsd   %xmm1, (%rdi)           ; store t'
    movq    $1277, -8(%rsi)         ; GC tag
    movsd   %xmm3, (%rsi)           ; store b'
    movq    $1277, -8(%rdx)         ; GC tag
    movsd   %xmm2, (%rdx)           ; store a'

    ; Build the 4-element tuple (40 bytes: 8-byte header + 4 pointers)
    movq    $4096, -8(%rax)         ; tuple header tag
    movq    %rdx, (%rax)            ; pointer → a'
    movq    %rsi, 8(%rax)           ; pointer → b'
    movq    %rdi, 16(%rax)          ; pointer → t'
    movq    %rbx, 24(%rax)          ; pointer → p'
    ret

.L102:
    call    caml_call_gc            ; garbage collection
    jmp     .L104

Every call to step allocates 104 bytes: four 16-byte boxed floats plus a 40-byte tuple to hold pointers to them. It also performs a GC boundary check and may trigger a garbage collection if the minor heap is full.

OxCaml: zero allocation #

    vaddsd  %xmm1, %xmm4, %xmm0   ; a' = (a + b) / 2
    vdivsd  %xmm3, %xmm0, %xmm0
    vmulsd  %xmm1, %xmm4, %xmm1   ; b' = sqrt(a * b)
    vsqrtsd %xmm1, %xmm6, %xmm1
    vsubsd  %xmm0, %xmm4, %xmm4   ; d = a - a'
    vmulsd  %xmm5, %xmm3, %xmm3   ; p' = 2 * p
    vmulsd  %xmm4, %xmm5, %xmm5   ; t' = t - p * d * d
    vmulsd  %xmm4, %xmm5, %xmm4
    vsubsd  %xmm4, %xmm2, %xmm2
    ret                           ; a' in xmm0, b' in xmm1,
                                  ; t' in xmm2, p' in xmm3

Just the pure arithmetic steps: four values go in via XMM registers, four come back out the same way.

Benchmark #

The algorithm is too fast to benchmark on its own, so I run the computation in a for loop 10 million times, resulting in 250 million step calls and measured the results with hyperfine on Linux (10 runs, 3 warmup), showing that OxCaml is 1.44x faster.

Benchmark 1: ./pi_ocaml
  Time (mean +/- s):      2.931 s +/-  0.048 s    [User: 2.927 s, System: 0.004 s]
  Range (min ... max):    2.854 s ...  3.020 s    10 runs

Benchmark 2: ./pi_oxcaml
  Time (mean +/- s):      2.033 s +/-  0.090 s    [User: 2.018 s, System: 0.015 s]
  Range (min ... max):    1.945 s ...  2.188 s    10 runs

Summary
  ./pi_oxcaml ran
    1.44 +/- 0.07 times faster than ./pi_ocaml

Results #

Both versions produce the correct value!

Pi = 3.141592653589794  (error: 8.88e-16)

Why [@inline never]? #

The [@inline never] annotation is worth mentioning as without it, the two compilers behave differently, resulting in a different comparison.

OxCaml inlines step into gauss_legendre, eliminating the function call entirely, resulting in a loop which is just the register arithmetic with no call instruction at all:

; OxCaml gauss_legendre WITHOUT [@inline never] - step is fully inlined
.L124:
    vmulsd  %xmm0, %xmm1, %xmm4   ; b' = sqrt(a * b)
    vsqrtsd %xmm4, %xmm5, %xmm4
    vaddsd  %xmm0, %xmm1, %xmm0   ; a' = (a + b) / 2
    vdivsd  %xmm5, %xmm0, %xmm0
    vsubsd  %xmm0, %xmm1, %xmm1   ; d = a - a'
    vmulsd  %xmm1, %xmm2, %xmm6   ; t' = t - p * d^2
    vmulsd  %xmm1, %xmm6, %xmm1
    vsubsd  %xmm1, %xmm3, %xmm3
    vmulsd  %xmm2, %xmm5, %xmm2   ; p' = 2 * p
    ...
    jmp     .L124                   ; loop — no call instruction anywhere

OCaml 5.4.0 does not inline step, even without the annotation. The loop still contains call camlPi_ocaml_inline.step_274@PLT, and every call still boxes four floats and builds a tuple on the heap:

; OCaml gauss_legendre WITHOUT [@inline never] - step is NOT inlined
.L111:
    cmpq    $1, %rdx                ; base case check (i = 0)
    je      .L110
    movq    %rdx, (%rsp)            ; spill loop counter
    call    camlPi_ocaml_inline.step_274@PLT  ; still a real call
    movq    24(%rax), %rsi          ; unbox returned tuple
    movq    16(%rax), %rdi
    movq    8(%rax), %rbx
    movq    (%rax), %rax
    jmp     .L111                   ; loop - passing boxed pointers

So [@inline never] on the OxCaml version levels the playing field. It prevents flambda2 from optimising away the very overhead I was trying to measure. Without it, the gap would be even larger, because OxCaml would inline everything while OCaml would still be boxing.

In practice, [@inline never] also represents the realistic case: real applications are made of many modules, and calls across compilation-unit boundaries cannot be inlined by either compiler. The boxing overhead shown here occurs whenever a function in one .ml file returns multiple float values to a caller in another.

Note #

I wrote this post last weekend in readiness for Pi Day, not realising that I would have the chance to use these techniques during the week in my OxCaml inference engine.