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.
ea873340202c5f0edf34dfd9a253d7713c4f6e93
(committed native binaries and committed js/wasm bundles; the bench
drivers were added by the same change that adds this page).tools/bench-runtimes.sh
— rerunnable end to end; raw machine-readable output at
docs/test-results/runtime-bench.json.
Median of 3 runs per cell via bash EPOCHREALTIME; timeout 600
per run; a cap trip is recorded as a skip, never silently dropped.
Peak-RSS numbers use
tools/bench_rusage_run.py
(getrusage(RUSAGE_CHILDREN); /usr/bin/time -v is not installed
in this sandbox). Four delta-log rows (the ocaml-native rows and
c-wasm N=10,000) were re-measured manually with the identical
protocol minutes after the main run, following a harness build bug
fixed in the same commit — flagged in the JSON's notes field.rdf:type + foaf:name + ex:dept cycling 20 buckets +
foaf:knows ring edge) at 100,000 and 1,000,000 triples, generated
deterministically by the harness. Synthetic because no suitably
large real corpus is checked into the repo
(third_party/data/ukparliament/ ships query text + readme only;
the demo .ttl samples are under 50 lines) — same precedent as
tools/bench-parse-serialize.sh.factoidal_cli
driver the native binary links):
<runtime> count multi-N.ttl<runtime> query --data multi-N.ttl --query <q>.rq -o jsonWall-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:
100000 triples
output, 0.463–0.470 s across runs).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).
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:
pure: hand-build a delta_batch of N entries in the host
language, time serialize_delta_batch then parse_delta_batch.
Available for OCaml-native, C-native, and C-wasm. (The shipped
js/wasm bundles do not export the raw serialize/parse functions.)sparql: one INSERT DATA { …N triples… } string through
parse_sparql_update → update_ops_to_delta_entries →
serialize_delta_batch → hex — the deltaBatchToHex export the
browser persistence path actually ships. Available for
OCaml-native, js_of_ocaml, and wasm_of_ocaml.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:
getrusage). See "what this reveals".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:
pure mode
handles N=10,000 in under a second. Filed as a perf-opportunism
observation below.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:
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.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.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.
pure-mode delta-log for the js/wasm bundles — the raw
serialize/parse functions are not exported by the shipped bundles;
only the SPARQL-driven deltaBatchToHex wrapper is measurable
there (sparql mode).Recorded per the standing order in
skills/perf-benchmarking/SKILL.md:
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.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:
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.