Home
OCaml Workshop 2026 · Experience report

JSON parsing in OxCaml:
fast, but not too fast

An experience report on building a simdjson-style parser with OxCaml’s performance features.

A geometric orange camel framed by a rusted gear
github.com/artempyanykh/simdjson-oxcaml
The experiment

Can OxCaml make the whole parser fast?

raw bytes{"x":[12,true]}
native OxCaml parser
scanparsebuild tree
SIMDunboxed typesstack allocationzero_alloc
Json.towned · pattern-matchable
The answer, first

Ahead of OCaml parsers; behind C++

Jsont
136
Yojson.Safe
150
simdjson-ox · one-shot DOM
659
simdjson-ox · reusable DOM
730
RapidJSON DOM
1,060
simdjson-ox · reusable tape
1,248
sajson DOM
1,651
C++ simdjson DOM
3,345 MB/s
  • reusable — one parser kept warm across documents
  • one-shot — fresh buffers on every call
Background

OxCaml primer

Unboxed types · SIMD support · stack allocation · zero_alloc
OxCaml primer · 1/4

Unboxed types

ordinary OCaml

let twice (x : float) =
  x +. x
register / stack slotOCaml heap
pointer
header
Double_tag
3.14
64-bit payload

OxCaml unboxed type

let twice (x : float#) =
  Float_u.add x x
register / stack slot
3.14 · raw 64 bits
no pointer · no float box
2-word heap block plus one indirection
stored and passed directly without allocation
OxCaml primer · 2/4

SIMD support

int8x16#one 128-bit register · sixteen 8-bit lanes
{
"
a
"
:
1
,
"
b
"
:
2
}
·
·
·
equal every lane with "
00
FF
00
FF
00
00
00
FF
00
FF
00
00
00
00
00
00
let bytes =
  Int8x16.String.unsafe_get input ~byte:0 in
let quote = Int8x16.const1 #34s in
let matches = Int8x16.equal bytes quote in
Int8x16.movemask matches
one bit per lane0b0000001010001010
OxCaml primer · 3/4

Stack allocation

type point = {
  x : int;
  y : int;
}

let make_points () =
  let heap = { x = 1; y = 2 } in
  let local = stack_ { x = 3; y = 4 } in
  print_int (local.x + local.y);
  heap
heap · returned
GC-managed heap

{ x = 1; y = 2 }
local · used here
make_points region
stack_ point
x = 3
y = 4
returned value must survive the call
stack region is reclaimed on return

@ local constrains where a value may flow; stack_ is what forces where it is allocated. A heap value is accepted anywhere a local one is—so a @ local return type alone guarantees nothing.

OxCaml primer · 4/4

Zero-allocation checking

let[@zero_alloc] wrap x =
  Some x

(* [Some x] allocates a block *)
Error: zero_alloc check failed

This function can allocate.
The allocation is not permitted by
the [@zero_alloc] annotation.
[@zero_alloc]checked in every build
[@zero_alloc opt]checked only in optimized builds
Background

simdjson primer

Structural index · top-down parser · tape-backed DOM
simdjson primer · scanner

The scanner produces a structural index

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"
x
"
:
[
1
2
,
t
r
u
e
]
}

Positions where parsing decisions can occur—not parsed values.

simdjson primer · parser

The parser builds values from the index

{"x":[12,true]}
position
input
parser action
0
{
begin object
1
"x"
parse key string
4
:
require key/value separator
5
[
recurse into array
6
12
parse number
8
,
continue array
9
true
parse boolean
13
]
finish array
14
}
finish object
At [, the parser knows an array starts—not how long it will be.
simdjson primer · representation

The parser writes values to a tape

0Root→ 8
1{→ 8
2"x"
3[→ 7
4Int 12
5True
6]→ 3
7}→ 1
8Root→ 0
opening entry → first entry after its scope
closing entry → corresponding opening entry

Values remain in document order; decoded strings live in a side buffer.

simdjson primer · API

The DOM borrows the parser’s tape

dom::parser parser;
dom::element doc = parser.parse(json);
auto answer = doc["answer"].get_int64();
for (auto item : doc["items"].get_array()) {
  use(item);
}

parser.parse(next_json); // reuses the tape

One live document per parser

The next parse reuses the buffers backing doc. Existing elements can no longer be used.

The implementation target

simdjson-oxcaml

borrowed tape · owned Json.t
From simdjson to simdjson-oxcaml

The project exposes both output contracts

borrowed Tape.t
let p = Tape.Parser.create ()
let tape = Tape.Parser.parse p input |> Result.get_ok

let root = Tape.tag tape 0
let entries = Tape.length tape

let _next = Tape.Parser.parse p next |> Result.get_ok
(* [tape] is now invalid *)
owned Json.t
let p = Parser.create ()
let json = Parser.parse p input |> Result.get_ok

match json with
| Json.Object fields -> use fields
| _ -> ()

let _next = Parser.parse p next |> Result.get_ok
(* [json] is still valid *)

Same scanner and parsing routines; different storage and lifetime.

Implementation · stage 1

Building the scanner

express the paper · measure · optimize
Build stage 1

OxCaml SIMD maps directly to the scanner

quotes and string regions
let all_quotes =
  Int8x64.equal Int8x64.ascii_quote block in
let quotes_mask =
  Int64_u.(all_quotes &~ escaped_mask) in
let string_mask =
  Int64_u.(prefix_xor quotes_mask lxor state_mask)
operators and scalar starts
let low = lookup ~table:low_lut block in
let high = lookup ~table:high_lut (high_nibbles block) in
let klass = Int8x64.(low land high) in
let operator_mask =
  Int8x64.equal
    Int8x64.(klass land vector_group_operators)
    (Int8x64.zero ())
  |> Int64_u.lnot
four int8x16# registers
= 64 input bytes
110011101100011…
one int64# structural mask
one bit per input byte

Here Int8x64.equal returns the 64-bit lane mask directly—compare and movemask fused—so vector results compose with ordinary Int64_u bit operations.

First measurement

The first scanner reached only 0.4 GB/s

0.4 GB/sfaithful first implementation · twitter.json

Correct SIMD source was not enough.

A flat orange-and-charcoal illustration of an engineer facepalming
Scanner bottleneck · 1/3

Allocation

structural output · fold state
1 · Allocation · structural output

Dynarray allocated per structural position

64-byte SIMD mask
extract each set bit
Dynarray.add_last
branch · grow · boxed slot
one append—and one slot block—for every structural position
1 · Allocation · structural output

Packed masks removed output growth

n = (length + 63) / 64
allocate int64# array once
write one 64-bit mask
per 64 input bytes
no resizing · no per-index append · one bit per input byte
1 · Allocation · loop state

The fold loop still allocated twice per block

Simd.Int8x64.String.fold_blocks input
  ~init:{ state = initial_state; block_idx = 0 }
  ~f:(fun { state; block_idx } block ->
    let result = scan_block state block in
    Structure.set_mask structure block_idx
      result.structural_mask;
    { state = result.state;
      block_idx = block_idx + 1 })
|> ignore
per 64 input bytesscan_block returns a boxed record
the fold returns another
afterboth records became #{ ... }
packed output and unboxed loop state
together: ≈ 2 GB/s
Scanner bottleneck · 2/3

Inlining

allocation is gone · helper calls remain
2 · Inlining · inspect the hot loop

Even a tiny nested helper remained a call

inside Chars.process
let process block =
  let low_class = lookup ~table:low_lut block in
  let high_class =
    block |> high_nibbles |> lookup ~table:high_lut
  in
  let klass = low_class land high_class in
  let make_group_mask group =
    equal (klass land group) (zero ())
    |> Int64_u.lnot
  in
  let operator_mask =
    make_group_mask vector_group_operators
  in
  ...
linked hot loop
… enter Chars.process
… compute klass
call make_group_mask
… resume classification …

Its definition and every call site were visible inside one top-level function.

2 · Inlining · result

Tactical inlining doubled throughput

≈ 2 → ≈ 4 GB/s26 inline-always annotations in the scanner + SIMD helper path

Still required under -O3: removing them costs 11–28% and puts allocation back into a zero-allocation path.

Scanner bottleneck · 3/3

Lowering

verify builtins in the linked binary
3 · Lowering · package metadata

Integer intrinsics lacked [@@builtin]

installed package
external count_set_bits = ...
  [@@noalloc]
external count_trailing_zeros = ...
  [@@noalloc]
project-local declarations
external count_set_bits = ...
  [@@noalloc] [@@builtin]
external count_trailing_zeros = ...
  [@@noalloc] [@@builtin]
without [@@builtin]
call caml_int64_popcnt_unboxed_to_untagged@PLT

caml_int64_popcnt_unboxed_to_untagged:
  call __popcountdi2
with [@@builtin]
popcnt  %rax, %rax
ret
popcountpopcnt · ctz → CPU instruction · popcount case: +11%
Scanner result

The scanner reached 6 GB/s

structural scanner

6 GB/sproduce packed masks

load + traverse ceiling

22 GB/sSIMD load + checksum · same inputs

That measures mask production; the parser still has to visit every structural position.

Stage 1 output

Structural indices

produce · consume · retain memory
Stage 1 → stage 2

The scanner is fast; now the parser needs positions

packed masks
64-bit mask
64-bit mask
exact size · one bit per input byte
flat indices
0
1
4
5
C++ simdjson design · reserve one int32 slot per input byte
Dynarray
len / capacity
0
1
4
grow and append positions as they arrive
Measure the handoff

Producing masks and consuming positions favor different layouts

allocation · workloadpackedflatDynarray
reusable · produce5,9924,1031,022
reusable · produce + consume3,1683,499923
one-shot · produce4,9471,050352
one-shot · produce + consume2,8521,001339
MB/s · geometric meanproduce + consume charges the cost of visiting every structural index
Packed capacity
input / 8
Flat capacity
input × 4
Benchmarking is part of the design

Flat helps only when its 4× buffer is already warm

public usage modepacked DOMflat DOMwhat changed
Parser.parse p input
reusable · warmed
717 MB/s742 MB/sflat +3.5%
effectively a tie
Simdjson.parse input
one-shot
646 MB/s486 MB/sflat −24.7%
packed capacity
input / 8flat capacity
input × 4

Packed gives up a negligible warmed-parser win and avoids touching 32× more index storage.

Implementation · stage 2

Parsing

borrowed tape · owned Json.t
Parse the structural stream

With packed indices fixed, compare both output APIs

structural indices
numbers · strings · grammar
tape
Json.t
Output A · borrowed tape

Borrowed tape peaks at 1.2 GB/s

1.2 GB/sreusable borrowed tape · 0 allocated words

0 minor and 0 major words on all seven fixtures—measured. [@zero_alloc] covers the leaf helpers; the composed public path is not annotated, so the property is observed rather than certified.

Output B · owned tree

Owned Json.t runs at 730 MB/s

730 MB/sreusable owned Json.t
match json with
| Object fields -> ...
| Array items -> ...
| Int n -> ...
| Float f -> ...
| _ -> ...
Output cost

Owning Json.t

containers · scratch buffers · write barriers
Owned tree · container path

Json.t arrays use shared scratch storage

parse_array on {"x":[12,true]}
1 · enter array
parser-owned Value_buffer
mark
·
·
mark the current scratch end
2 · call parse_value
parser-owned Value_buffer
markend ↓
Int 12
Bool true
append each owned Json.t
3 · see ]
Json.Array · exact result
Int 12
Bool true
copy slice · reset scratch end
Owned tree · buffer reuse

Reusing that scratch array makes it long-lived

parse 1
grow backing array
parse 2
reset length, keep capacity
parse 3
young nodes → old array

Amortized growth, but every young pointer write crosses the GC barrier.

Change the allocation shape

For small arrays, parse directly into the result

general path
[12,true]
append twice to old scratch
allocate exact array
copy twice
≤ 4 elements
let v0 = parse_value state
let v1 = parse_value state
one exact young array
+31% on canada.json · small-container path + buffer changes
Representation meets the ecosystem

Language and libraries

object buffers need operations · leaves need representation
Object buffers meet the libraries

Flattening object fields exposed a library gap

type assoc = {
  key : string;
  value : Json.t;
}

assoc# array
(* layout: value & value *)
What the parser needs
append fieldsbulk blitexact result
reusable scratchbulk fill / clear
assoc# saves one heap block per field. Using it here also requires efficient blit and fill.
Library gap · attempted workaround

value & value lacked a complete bulk-operation path

Library surface
neither Stdlib nor Base exposed blit + fill
for arrays with layout value & value
Custom blit
bind a special external directly to %arrayblit
compiler emits a loop with two caml_modify calls per field
Still no fill
clearing scratch remained a handwritten product loop
two more caml_modify calls + one poll per field
Value arrays
standard caml_array_blit + caml_array_fill
one pair of runtime calls for the complete slice
Parser fallback

So the parser boxed associations

one extra record block
per field
caml_array_blit
+ caml_array_fill

Fixture-dependent: from a 10% loss to an 8% win. A tradeoff forced by the missing operations, not a universal speedup.

Owned tree · leaves

Owned numeric leaves still allocate

Float of
float#
one constructor block
payload stored inline

Objects hit a library gap; leaves hit a language one.

Highest-value language extension

Unboxed variants could avoid leaf blocks

today

Int of int
Float of float#

constructor block per leaf

desired

same pattern-matchable Json.t

unboxed numeric alternatives

The other performance gap

The parser core

OxCaml tape · C++ simdjson
Back to the parser core · why 1.2 ≠ 3.3 GB/s

The OxCaml parser simply does more work

C++ simdjson

15.8instructions / byte

OxCaml tape

≈ 33instructions / byte
Parser core · one slice of that work

The decimal-integer loop carries extra runtime work

C++ simdjson8
.loop:
imul $0xa,%rdx,%rdx # value *= 10
movzbl %cl,%ecx # digit
add $0x1,%rax # next byte
add %rcx,%rdx # value += digit
movzbl (%rax),%r12d # load byte
lea -0x30(%r12),%ecx # byte - '0'
cmp $0x9,%cl # still a digit?
jbe .loop
OxCaml tape19
.loop:
mov %rsi,%rax
sar $1,%rax # untag position
movzbq 0(%rbp,%rax),%rax # load byte
lea -0x5f(%rax,%rax),%r8 # tagged digit
cmp $1,%r8
jl .done
cmp $0x13,%r8
jg .done # digit range
add $2,%rsi # tagged position++
cmp %r13,%rsi
setl %al
movzbq %al,%rax # materialize bound
sar $1,%r8 # untag digit
imul $0xa,%rdi,%rdi
add %r8,%rdi # value = 10v + d
cmp (%r14),%r15
jbe .poll # runtime poll
test %rax,%rax # that bound, as a value
jne .loop
cyan parsing work · orange tagged values / explicit bound · violet runtime poll

19 vs 8 is a count, not a time ratio: most of the extra work runs for free alongside the dependency chain. Micro-optimization is left here—which of it pays is not obvious from the source.

Synthesis

Lessons

what worked · what took work
Lessons · 1/2

What worked

SIMD
the paper's scanner ported straight across—vectors, intrinsics, unboxed records
no C calls on the hot path · 6 GB/s
Unboxed types
packed int64# masks, unboxed loop state, float# payloads
allocation removed where it actually mattered
zero_alloc
a property the compiler rechecks, not a hope—wherever you put it
annotated helpers stay allocation-free as the code changes
The API
an owned, pattern-matchable Json.t at 730 MB/s
roughly 5× the existing OCaml parsers—the goal was reachable
Lessons · 2/2

What took rethinking and elbow grease

Trust the binary
inlining and intrinsic lowering needed explicit guidance and assembly audits
26 [@inline always] · a missing [@@builtin] worth 11%
Port the idea
flat indices → packed masks; goto state machine → recursive descent
the faithful explicit-stack rewrite came out 8–10% slower
Shape beats tuning
changing what gets allocated moved more than tuning the GC that collects it
small-container path +31% · a 32M minor heap ≈ 1%
Fit the libraries
a flatter representation is only better if its bulk operations exist
value & value had no blit/fill, so associations went back to boxes
Measure
instruction count is not the clock—both directions surprised us
would help
downstream
unboxed variantsnon-value-layout library coveragekind & layout ergonomicsmore zero_alloc-shaped guarantees
Thank you

Questions?

Artem Pianykh
github.com/artempyanykh/simdjson-oxcaml
pianykh.com/blog/talks/
ocaml-workshop-2026.html
backup slides: benchmark setup · per-fixture results · what each row owns · structural-index matrix · does -O3 replace the annotations? · instructions vs the clock · conformance · template kinds · agents · what's next
Appendix

Backup

methodology · full results · scope
Backup · how the numbers were produced

Benchmark setup

Machine
Intel Core i7-12700K · pinned to logical CPU 0, a Golden Cove P-core
12 MiB L2, 25 MiB shared L3 · 62 GiB RAM, no swap in use
Run conditions
performance governor and EPP · ASLR off via setarch -R
turbo enabled, frequency not locked · machine lightly loaded
Toolchain
OxCaml 5.2.0+ox · Dune 3.22.2 · GCC 15.2.1, CMake Release
Fedora 43, Linux 6.19.14 · commit 423c6f2, 2026-08-15
Method
Bechamel for OCaml, Google Benchmark for C++ · fixtures loaded before timing
per-case resource isolation · geomean over 7 simdjson-data inputs
Backup · geomean hides the spread

Per-fixture throughput

fixtureYojson.Safeox DOMox tapeRapidJSONC++ simdjson
apache_builds2046781,5558675,835
citm_catalog1831,6042,0721,6495,556
gsoc-20183471,0872,2571,0926,522
twitter2101,1121,4281,0485,515
numbers1115578941,0471,640
canada744347599901,525
marine_ik763466688851,605
geomean1507301,2481,0603,345
MB/s · reusable OCaml rowsorange: tape ahead of RapidJSON · violet: tape behind it
Backup · do the rows do the same work?

What each row produces

Owned trees
Yojson · Jsont · simdjson-ox DOM · RapidJSON · nlohmann
caller keeps the result; parser state can be discarded
Borrowed results
simdjson-ox tape · C++ simdjson DOM · sajson
handles into parser-owned storage, invalidated by the next parse
Held constant
full materialization, never validation-only or scan-only
fixtures preloaded · buffers hot · harness picks iteration counts
Not equivalent
checksums are per-driver smoke guards, not a cross-parser proof
large integer literals can differ; UTF-8 validation strictness differs

The honest comparison is owned-vs-owned; the tape row is on the chart to isolate what materializing a tree costs.

Backup · what packed costs the iterator

Structural index: production, consumption, memory

allocation · workloadpackedflatDynarray
reusable · produce5,9924,1031,022
reusable · produce + consume3,1683,499923
one-shot · produce4,9471,050352
one-shot · produce + consume2,8521,001339
Decode cost
consuming every index costs packed 47% of its production throughput
5.99 → 3.17 GB/s · flat then leads by about 10%
Retained memory
canada.json: packed 281 KB · flat 9.0 MB · Dynarray 448 K slots
packed and flat allocate zero GC words per reusable scan; Dynarray does not
Backup · could a preset have done it instead?

No: -O3 does not replace the annotations

buildDOM numbersDOM twittertape numberstape twitter
current (default preset, annotated)5081,0739011,477
-O2, annotated4991,0768971,443
-O3, annotated4991,0798961,441
-O3, annotations removed366 −28%957 −11%657 −27%1,205 −18%
…plus explicit exclave_ stack_628 0 words1,205 0 words
MB/s · median of three pinned-CPU runs · identical checksumsthe preset itself is a wash: ±2%
the annotations
do two jobs
allocation-free by accident: inlining deletes the recordsfast: scalar replacement beats stack records

Five number-scanning records were @ local without stack_, so they were heap-allocated: 0 → ~109,000 words once inlining stopped removing them. Adding exclave_ stack_ restores 0 words by construction—but not the speed.

Backup · why the digit loop resists tuning

Instruction count is not the clock

number-parsing checkpointtape MB/sinstructions
investigation baseline84245.14B
remove redundant finite checks85743.94B
unroll first scalar tail digit88544.34B
outline cold path, trim scan result89942.13B
reusable tape · numbers.jsonthe largest gain added instructions

The loop is limited by the 10·v + d dependency chain, not by how many instructions surround it.

Backup · correctness

Conformance and tests

jsonchecker
105 / 105 conformant · 0 internal exceptions
vendored upstream fixtures, reported as an expect test that tracks drift
Cross-path parity
tape parser vs tree parser on every fixture
reusable parser vs stateless parser on every fixture
Scanner
SIMD scanner vs an independent scalar scanner
equivalence checked on representative inputs
Property tests
generated valid JSON compared against expected DOM values
plus numeric and string edge-case suites
Backup · trying to add the missing library case

Template case lists cannot name repeated product kinds

built-in abbreviations

value
immediate
immutable_data
Named kind-abbreviation syntax is already part of the grammar.

what templates still require

[@@kind k =
  (value,
   value & value,
   (value & value) & value, ...)]
The public syntax exposes only the fixed built-ins. These repeated product kinds cannot be given library-defined names yet.
Backup · coding agents on this project

Delegate what a benchmark can check

Codex harness driven by an OCaml/OxCaml skill: github.com/artempyanykh/ocaml-codex
the same idea as avsm/ocaml-claude-marketplace for Claude Code

Delegated
work with an automatic oracle—tests, benchmarks, harness and corpus wiring
plus anything I had no strong opinion about
Kept
anything needing subjective judgement: API shape, representation, what to measure
agent output here was consistently poor
Still steered
even validatable work: bogus benchmark results, or circling the same dead end
the strongest models included
Best use
fuzzy search—finding things across an unfamiliar toolchain, package, and compiler
net: a real time saving
Backup · scope today

Limitations and what comes next

Status
no production or downstream use—this is an experience report, not a released library
source is public; the parser exists to answer the question in this talk
Errors
failures return Invalid_json without a stable position or category
known follow-up; conformance is complete but diagnostics are not
CPU targets
SSE only — the 64-byte block is four 128-bit int8x16# registers
every vector op goes through Ocaml_simd_sse
AVX
available in ocaml_simd, unused here — a to-do, not a blocker
wider blocks would also need runtime CPU feature dispatch to select the path
ARM
blocked on upstream ocaml_simd exposing the interfaces
Representation
unboxed variants would remove the per-leaf block behind the same API
designed upstream, not yet implemented