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.
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.
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 · workload
packed
flat
Dynarray
reusable · produce
5,992
4,103
1,022
reusable · produce + consume
3,168
3,499
923
one-shot · produce
4,947
1,050
352
one-shot · produce + consume
2,852
1,001
339
MB/s · geometric meanproduce + consume charges the cost of visiting every structural index
Packed capacityinput / 8
Flat capacityinput × 4
Benchmarking is part of the design
Flat helps only when its 4× buffer is already warm
public usage mode
packed DOM
flat DOM
what changed
Parser.parse p input reusable · warmed
717 MB/s
742 MB/s
flat +3.5% effectively a tie
Simdjson.parse input one-shot
646 MB/s
486 MB/s
flat −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 fields→bulk blit→exact result
reusable scratch→bulk 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
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
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
fixture
Yojson.Safe
ox DOM
ox tape
RapidJSON
C++ simdjson
apache_builds
204
678
1,555
867
5,835
citm_catalog
183
1,604
2,072
1,649
5,556
gsoc-2018
347
1,087
2,257
1,092
6,522
twitter
210
1,112
1,428
1,048
5,515
numbers
111
557
894
1,047
1,640
canada
74
434
759
990
1,525
marine_ik
76
346
668
885
1,605
geomean
150
730
1,248
1,060
3,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 · workload
packed
flat
Dynarray
reusable · produce
5,992
4,103
1,022
reusable · produce + consume
3,168
3,499
923
one-shot · produce
4,947
1,050
352
one-shot · produce + consume
2,852
1,001
339
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
build
DOM numbers
DOM twitter
tape numbers
tape twitter
current (default preset, annotated)
508
1,073
901
1,477
-O2, annotated
499
1,076
897
1,443
-O3, annotated
499
1,079
896
1,441
-O3, annotations removed
366 −28%
957 −11%
657 −27%
1,205 −18%
…plus explicit exclave_ stack_
—
—
628 0 words
1,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 checkpoint
tape MB/s
instructions
investigation baseline
842
45.14B
remove redundant finite checks
857
43.94B
unroll first scalar tail digit
885
44.34B
outline cold path, trim scan result
899
42.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