Performance hub — one engine, four extraction targets#

The F* specification extracts to four executable forms: native OCaml (bin/<platform>/factoidal), js_of_ocaml (JavaScript, runs in Node and browsers), wasm_of_ocaml (WasmGC, needs Node ≥ 22 or a recent browser), and KaRaMeL C (today: the delta-log module family only, not the full engine). This page answers "how do those four compare on the same work" — a runtime-vs-runtime axis. The engine-vs-engine axis (factoidal vs Jena vs pyoxigraph vs rdflib) is a different question, measured in the competitive benchmark; correctness scores live on the test-results dashboard.

Every number below is a median of 3 runs (wall-clock seconds), each run capped at 600 s, measured 2026-07-06 at commit ea87334 on the machine described under Methodology.

The short answers#

Methodology#

Full engine: native vs js_of_ocaml vs wasm_of_ocaml#

Wall-clock includes process start-up, Turtle parse, index build where the query needs one, query evaluation, and JSON serialization — the end-to-end "run this query from a shell" cost. Node process + bundle-load overhead is roughly 0.1 s (js) / 0.3 s (wasm) per invocation, so it does not explain the gaps.

At 100,000 triples (median of 3 runs, seconds):

Operation native js_of_ocaml (Node) wasm_of_ocaml (Node)
Turtle parse (count) 0.63 2.66 0.47
SELECT (COUNT(*) …) full scan 0.63 2.58 2.61
2-pattern join + FILTER 14.92 38.54 34.62
GROUP BY + COUNT (20 groups) 1.71 7.40 3.11
regex FILTER on names 1.74 7.09 2.85

At 1,000,000 triples (median of 3 runs, seconds; each run capped at 600 s):

Operation native js_of_ocaml (Node) wasm_of_ocaml (Node)
Turtle parse (count) 6.46 26.43 4.08
SELECT (COUNT(*) …) full scan 6.59 25.13 35.35
2-pattern join + FILTER >600 s (skip) >600 s (skip) >600 s (skip)
GROUP BY + COUNT (20 groups) 20.91 484.20 270.29
regex FILTER not attempted (time budget) — —

Readings:

W3C-suite-per-runtime note. The wall-clock of the full W3C SPARQL suite per runtime is not reported here: the js/wasm W3C runners (docs/fstar-extracted/w3c-runner{.js,.wasm.js}) are exercised by tests/beyond-w3c/ for parity (same answers as native), and a per-runtime suite timing would mostly measure Node process spawns and manifest I/O rather than engine speed. The query-shape table above is the runtime-speed comparison; suite scores are runtime-independent (same extracted logic).

Delta-log micro-bench: the four-way (plus C→wasm) comparison#

RDF.Store.Columnar.DeltaLog — the durable-UPDATE delta-log byte format (serialize + parse of a batch of DE_Add entries, framing and checksum included) — is the one module family that exists in all four extracted forms today. Two distinct pipelines are measured; they are not comparable with each other:

Drivers: tools/deltalog-bench/ (deltalog_bench.c, deltalog_bench.ml, bench-js-wasm.mjs, run-wasi.mjs). All three pure binaries run with unlimited process stack (ulimit -s unlimited); the wasm module is additionally linked with a 1 GB wasm stack and run under node --stack-size=200000 — see "what this reveals" below for why.

pure mode — serialize + parse of an N-entry batch, process wall-clock (seconds)#

N entries OCaml native KaRaMeL C native KaRaMeL C → wasm32-wasi (node:wasi)
1,000 0.056 0.047 0.107
5,000 0.316 0.242 0.319
10,000 0.747 0.465 7.61 (see note)

Per-phase timings reported by the binary itself (single run at N=10,000; the serialized batch is 1,107,816 bytes):

Phase OCaml native C native C wasm32-wasi
serialize_delta_batch 0.214 s 0.221 s 0.291 s
parse_delta_batch 0.463 s 0.149 s 7.16 s

Notes:

sparql mode — INSERT DATA → deltaBatchToHex, wall-clock (seconds)#

The same wrapper function across the three runtimes that ship it (N counts triples in the INSERT DATA; in-process total_s shown in parentheses — the difference from wall is process + bundle start-up):

N triples OCaml native js_of_ocaml (Node) wasm_of_ocaml (Node)
100 0.39 (0.38) 0.67 (0.57) 0.41 (0.34)
300 3.46 (3.44) 5.00 (4.87) 2.89 (2.80)

Two things stand out:

The C→wasm question#

The question this page was commissioned to answer: could the KaRaMeL C pathway produce a better wasm than js_of_ocaml / wasm_of_ocaml?

How the C→wasm leg was built. No emscripten, wasi-sdk tarball, or zig was available in this sandbox, but Ubuntu's own apt archive carries a working wasm32-wasi toolchain: apt install wasi-libc libclang-rt-18-dev-wasm32, then clang --target=wasm32-wasi --sysroot=/usr compiles the KaRaMeL-generated Factoidal_DeltaLog.c (plus krmllib pieces and the demo stubs) unmodified. The resulting module runs under Node 22's built-in node:wasi (preview1). The 12-check correctness demo (formal/fstar/c-output/deltalog/demo/delta_log_demo.c) passes 12 of 12 checks under wasm exactly as natively — the C build is not just timeable under wasm, it is correct under wasm.

What the numbers say (delta-log micro-bench only): C→wasm is 1.0–1.4× C-native per phase at N≤5,000 — at that scale it is the fastest wasm-form of this module we can measure (its parse at N=5,000, 0.078 s, is 2.4× faster than OCaml-native's 0.185 s). But it collapses at N=10,000 (48× slower parse, intermittent stack death) where OCaml-native and, by the sparql-mode evidence, wasm_of_ocaml keep working. There is no N at which the C→wasm route demonstrated an advantage over the wasm_of_ocaml route on the same shipped functionality, because the two cannot run the identical pipeline — and where indirect comparison is possible, wasm_of_ocaml's showing against native OCaml (beating it on parse) is stronger than C-wasm's showing against C-native (cliff at N=10,000).

What this implies — and does not imply — for a full-engine KaRaMeL wasm build:

  1. The full engine cannot take this path today. krml's monomorphizer blows up (stack overflow / >10 min at >5 GB RSS) on the SPARQL11.Algebra / RDF.Graph.Executable dependency graph — reproduced and documented in tools/karamel-c-build.sh (Groups B and D) and scoped in the C-build plan. Only leaf modules with small dependency cones (delta log, RDF.Format, JSON escape, static files) extract to C at all.
  2. Even where it works, KaRaMeL C inherits the F* spec's data layout. The generated C processes linked lists of heap-allocated cons cells with deep non-tail recursion (serialize_ops, parse_n_delta_entries). Measured consequences: ulimit -s unlimited needed above ~1,000 entries; ~41 KB peak RSS per entry; OOM-killed at N=1M on 15 GB RAM — in native C. The wasm build additionally needs the wasm linker stack raised and V8's --stack-size raised, and still ceilings between N=10,000 and N=20,000. "C" does not mean "fast and lean" when the compiled program is a list-processing functional program in C clothing.
  3. wasm32-wasi vs WasmGC is a real architectural fork. The C route brings its own malloc heap in linear memory (GC-less krmllib compatibility mode — the demo and bench allocate and never free); wasm_of_ocaml targets WasmGC and inherits the host GC. For long-running in-browser sessions the memory story, not micro-bench latency, is likely decisive — and it favours WasmGC.
  4. What a fair full-engine comparison would need: either the krml monomorphizer blocker fixed / the algebra modules restructured into KaRaMeL-compatible form (the C-build plan's long track), or Low*-style rewrites of hot paths onto flat buffers — at which point the speed would owe more to the rewrite than to the C target. Until then, no full-engine C-vs-js number can be measured, and none is claimed here.

Bottom line: on today's evidence, KaRaMeL → C → wasm is not a shortcut to a faster full-engine wasm. The C build is the fastest native form of the one module family that has it, but compiled to wasm it gains no demonstrated advantage over the wasm_of_ocaml route and hits a scale cliff the other runtimes don't. Meanwhile wasm_of_ocaml already beats the native binary on parse throughput. A faster wasm engine is more likely to come from wasm_of_ocaml plus F*-side data-structure work (the same lesson as the 2026-04 Turtle-parser history) than from switching extraction pipelines.

What is NOT measured here#

Perf-opportunism observations (filed, not fixed here)#

Recorded per the standing order in skills/perf-benchmarking/SKILL.md:

  1. deltaBatchToHex scales super-linearly in update size on all runtimes (OCaml-native: N=100 → 1,000 went ~0.4 s → ~39 s, ~100× for 10× input). The wall is in SPARQL-Update parsing/translation, not delta-log serialization (pure mode does N=10,000 in under a second). Browser persistence writes batches of a few ops, so this does not bite today; bulk INSERT DATA through this path would.
  2. Delta-log extraction shape — non-tail-recursive serialize/parse recursion (stack) and cons-cell-per-byte output (heap, ~41 KB RSS per entry measured) put a hard scale ceiling far below the format's design limit of 2^32 ops per batch, on every runtime including C.
  3. GROUP BY at 1M is 23× slower on js than native (484 s vs 21 s) — the widest runtime gap measured; whatever allocation pattern aggregation uses is disproportionately expensive under js_of_ocaml.

Lean 4 engine vs the two F* runtimes (2026-08-22)#

Harness: tests/perf/l4_vs_fstar_wasm_bench.mjs, commit 8d4a389f7, node v22.22.2, macOS arm64, machine otherwise idle. Workload: K people with a :name and an :age triple (2K triples); query is the two-pattern join ?s :name ?n . ?s :age ?a. Each engine runs in its OWN child process, so RSS is not contaminated by a sibling heap. Query time is the median of 5 runs after 1 warmup. Re-running the same sweep under two concurrent Lean compiles changed every figure by under 5 %, so the numbers below are not contention artefacts.

Query, median milliseconds — the only like-for-like column, because both sides hold the data by then:

K (people) triples Lean wasm F* wasm_of_ocaml F* js_of_ocaml
100 200 4.4 3.8 9.8
1000 2000 136.8 26.3 79.0
4000 8000 1692.8 140.7 372.5

Ingest is NOT comparable across the two families and is reported separately, never folded into a ratio: the Lean ABI takes pre-built JSON triples (stringifyMs 0.1 / 0.8 / 5.2), while the F* engines parse Turtle (parseMs below). Init: Lean 42.4 ms, wasm_of_ocaml 35.5 ms, js_of_ocaml 49.5 ms.

K parse ms, wasm_of_ocaml parse ms, js_of_ocaml RSS MB (Lean / wasm / js)
100 8.6 19.1 32.2 / 26.4 / 41.6
1000 319.9 200.6 57.0 / 71.9 / 95.9
4000 2129.5 1276.7 137.6 / 111.6 / 174.4

Two findings, both filed:

  1. The Lean spec evaluator is quadratic in the data (#507). It is competitive at K=100 and 12.0× slower than wasm_of_ocaml at K=4000; its own cost rises 12.4× for a 4× data increase. This is not a defect in the port — SPARQL/Algebra.lean states that BGP matching is nested loops over lists, chosen so W3C reviewers can read it. The fix has a proved precedent already in the tree: the OWL closure got an indexed engine with indexedClosure g fuel = closure g fuel proved as list equality, so the naive definition stays the specification and every theorem transfers. The same shape applies to evalBgp.
  2. wasm_of_ocaml parses slower than js_of_ocaml above ~K=100 while querying much faster. At K=100 wasm parse is 2.2× FASTER (8.6 vs 19.1 ms); by K=1000 it is 1.6× slower and by K=4000 1.7× slower (2129.5 vs 1276.7 ms) — a crossover, not a constant offset — while query stays 2.6× faster at K=4000. Since parse dominates ingest, the backend choice is workload-dependent today: query-heavy favours wasm, parse-heavy favours js. Worth finding what in the Turtle parser penalises the wasm backend at scale.

See also#