A reproducible benchmark of one claim:
Route the JSON parsing hot loop out of the JVM into simdjson through JNI, and the win only survives if you keep the bytes off the managed heap.
The first half of that claim holds on this machine. The second half is more interesting than it sounds, and the numbers below qualify it rather than confirm it.
This repository is the open distillation of a C++/JNI parsing module built for vehicle-data pipelines at Porsche Informatik. Everything here runs on public data and public toolchains.
Four engines, one shared correctness contract:
| engine | what it does |
|---|---|
jackson-tree |
ObjectMapper.readTree into a JsonNode tree, then a recursive walk |
jackson-stream |
JsonParser token loop, no tree, no String materialisation |
simdjson-jni-copy |
simdjson On Demand over a Java byte[] via GetByteArrayElements |
simdjson-jni-direct |
simdjson On Demand over a DirectByteBuffer via GetDirectBufferAddress |
simdjson On Demand is lazy. A benchmark where the native side returns a handle without touching the data measures nothing. So every engine must produce the same 64-bit checksum, folded in document order over:
- every container begin and end,
- every object key's UTF-8 byte length,
- every string's UTF-8 byte length after unescaping,
- every integral number's int64 bit pattern,
- every non-integral number's IEEE-754 raw bit pattern,
- every boolean, and every null.
There is no way to produce that value without visiting every scalar in the document. String lengths are post-unescape, so On Demand must actually run the unescaper; number bits are exact, so both sides must fully parse every float.
EquivalenceMain asserts all four engines return the byte-identical checksum on all ten corpus
files plus two NDJSON vehicle-feed batches, and that the three ingestion pipelines produce
equals-identical List<Listing>. If checksums disagree the benchmark is invalid and
run_bench.sh aborts before any measurement runs.
Two details that fell out of enforcing this:
- Jackson 2.18's float parser and simdjson agree bit-for-bit on all 111,080 doubles in
canada.jsonand all 114,950 inmarine_ik.json. No tolerance was needed anywhere. twitter.jsonandtwitterescaped.jsonproduce different checksums. They are not the same document: the escaped variant has tweet IDs rounded at ~17 significant digits (505874924095815681vs505874924095815700), an upstream JavaScript round-trip artifact in the simdjson corpus. The checksum catching a 1-in-10^17 integer difference is a good sign it is really reading the numbers.
All real, none synthesised.
- The canonical simdjson corpus (real production API payloads):
twitter.json,twitterescaped.json,github_events.json,citm_catalog.json,canada.json,gsoc-2018.json,marine_ik.json,mesh.json,update-center.json. vpic_makes.json: a live response from the NHTSA vPIC production API (12,361 vehicle makes).vehicles.csv: the EPA fueleconomy.gov dataset, every US-market vehicle 1984-present.vehicles.ndjson: generated bytools/make_feed.pyfromvehicles.csv. A pure format conversion — one JSON object per line, per-column type inference, empty CSV cells becomenull. Nothing is invented; where the EPA data literally contains the string"NA"(3 rows incylinders, 2 indispl) the column stays a string column rather than being silently nulled.
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
bash scripts/run_bench.shThat single script fetches simdjson, builds the native bridge, builds the Java side, regenerates
the vehicle feed, records environment provenance, verifies engine equivalence, runs every JMH
suite, and writes results/.
Expect roughly 90 minutes. Pinned: simdjson 4.6.11, Jackson 2.18.2, JMH 1.37, Java 21, C++20,
-O3 -march=native, -Xms2g -Xmx2g -XX:+UseG1GC. JMH settings are forks=3, 5 warmup iterations,
10 measurement iterations, single-threaded except the scaling suite.
Outputs:
results/summary.json— the distilled result, sections A through F, with method notes.results/raw_*.json— untouched JMH output for every suite.results/environment.json— JDK build string, clang version, CPU, simdjson version and the implementation actually selected at runtime, dataset sizes and sha256s.results/equivalence.json— the checksums that gate the whole thing.results/feed_manifest.json— feed record count, byte count, sha256, inferred column types.
Regenerate the tables below with python3 tools/render_tables.py --summary results/summary.json.
- One machine. Apple M3, 4 performance + 4 efficiency cores, macOS. Nothing here generalises to a different CPU without re-running it.
- arm64 NEON, not AVX-512. simdjson selected its
arm64implementation at runtime. The x86-64 story — where simdjson has 256-bit and 512-bit kernels and the JVM does not — is very likely better for simdjson, but this repository does not measure it. - One JVM. OpenJDK 21 (Homebrew build) only. No GraalVM, no OpenJ9, no Azul. No cross-vendor claim is made or supported.
- No acquisition cost. Both the
byte[]and theDirectByteBufferare filled once in JMH@Setup. The benchmark measures steady-state parsing, not the cost of getting bytes into one representation or the other. If your bytes arrive from a socket into a heap buffer, you would pay a copy to reach the direct path that this benchmark never charges anyone for. - No FFM comparison. Java 21's Foreign Function & Memory API (
java.lang.foreign) is the modern alternative to JNI and is not measured here. The JNI crossing costs in section D should not be read as "the cost of calling native code from Java in 2026". - Scaling is not a server model. The 1/2/4-thread numbers share a single input buffer on a machine with asymmetric cores, and the OS is free to schedule onto efficiency cores. They show whether the parse itself scales, not what a real ingestion service would do.
gc.count/gc.timeare run-scoped, not per-operation, and depend on the pinned 2 GB heap.gc.alloc.rate.normis the portable number.- The pipeline stage split is a difference of measured variants, not a profiler attribution.
Stages smaller than the combined confidence interval are flagged
[below noise]and should be read as "too small to resolve", not as a value. - The machine was not otherwise idle-guaranteed. Error bars are reported for every score so you can see the run-to-run stability rather than trusting a single number.
native/simdjson_jni.cpp the JNI bridge: direct, copy, batch-extract, no-op
src/main/java/... engines, pipelines, checksum contract, equivalence gate
src/main/java/.../bench/ JMH suites A-F
tools/make_feed.py vehicles.csv -> vehicles.ndjson
tools/summarize.py raw JMH JSON -> results/summary.json
tools/render_tables.py results/summary.json -> the markdown above
scripts/fetch_deps.sh vendors simdjson 4.6.11 singleheader
scripts/run_bench.sh everything, from a clean checkout
The native bridge exposes four entry points: a full-document traversal over a direct buffer
(zero copy), the same traversal over a byte[] (the copying bridge), a batch field-extraction
call that writes extracted columns for a whole NDJSON batch into caller-supplied direct buffers
so there is one boundary crossing per batch rather than one per field, and a no-op used to price
the crossing itself. It uses the On Demand API with a thread_local parser, respects
SIMDJSON_PADDING (64 bytes, allocated as slack on every input buffer on the Java side), and
turns every simdjson error into a Java SimdJsonException rather than a crash.
Generated from results/summary.json. Hardware: Intel(R) Xeon(R) Platinum 8480+, simdjson 4.6.11 on its icelake (Intel/AMD AVX512) kernel, OpenJDK 64-Bit Server VM 21.0.12.1+1-LTS.
Engine equivalence gate: 0 mismatches across 12 datasets.
| dataset | jackson-tree | jackson-stream | simdjson-jni-copy | simdjson-jni-direct | speedup |
|---|---|---|---|---|---|
| canada.json | 70 ± 1 | 87 ± 1 | 751 ± 5 | 790 ± 5 | 9.12x |
| citm_catalog.json | 550 ± 4 | 820 ± 6 | 2552 ± 16 | 3054 ± 18 | 3.72x |
| github_events.json | 371 ± 3 | 525 ± 9 | 2822 ± 25 | 3000 ± 28 | 5.71x |
| gsoc-2018.json | 648 ± 5 | 891 ± 7 | 3139 ± 21 | 4057 ± 27 | 4.56x |
| marine_ik.json | 163 ± 1 | 223 ± 3 | 715 ± 4 | 751 ± 5 | 3.36x |
| mesh.json | 149 ± 1 | 186 ± 1 | 710 ± 5 | 717 ± 4 | 3.86x |
| twitter.json | 305 ± 3 | 474 ± 4 | 2483 ± 17 | 2672 ± 26 | 5.64x |
| twitterescaped.json | 290 ± 2 | 451 ± 5 | 1106 ± 8 | 1143 ± 8 | 2.53x |
| update-center.json | 225 ± 2 | 370 ± 3 | 1942 ± 14 | 2069 ± 15 | 5.58x |
| vpic_makes.json | 201 ± 3 | 336 ± 3 | 1297 ± 9 | 1355 ± 8 | 4.03x |
Corpus aggregate (total bytes over total time): 1257 MB/s for the zero-copy path against 244 MB/s for the best JVM engine, a factor of 5.16.
- One JNI crossing into an empty native function: 10.15 ns (plain Java call: 0.34 ns).
- Batch API, one crossing per 4,096-record batch: 0.00022% of pipeline time.
- Hypothetical one crossing per field (32,768 crossings): 7.2% of pipeline time.
The boundary is cheap. Crossing it often is not.
| engine | bytes per parse | bytes per input byte | GC events |
|---|---|---|---|
| jackson-tree | 10.6 MB | 6.6 | 29 |
| jackson-stream | 6.0 MB | 2.9 | 9 |
| simdjson-jni-copy | 8 B | 5.4e-06 | 0 |
| simdjson-jni-direct | 7 B | 5.1e-06 | 0 |
| engine | mean | p50 | p99 | p99.9 | samples |
|---|---|---|---|---|---|
| jackson-tree | 1796.1 | 1787.9 | 2732.2 | 4658.0 | 33,395 |
| jackson-stream | 1365.3 | 1351.7 | 1664.0 | 3905.0 | 43,924 |
| simdjson-jni-copy | 297.5 | 290.3 | 433.2 | 833.1 | 201,473 |
| simdjson-jni-direct | 287.4 | 282.1 | 354.8 | 766.0 | 208,577 |
The p99/p50 ratio is essentially unchanged between families (1.23 for jackson-stream against 1.26 for the zero-copy path). Everything got faster in proportion; predictability did not improve.
| engine | full pipeline | parse | map | validate | MB/s |
|---|---|---|---|---|---|
| jackson-tree | 22.5 ms | 96.3% | 1.2% | 2.6% | 252 |
| jackson-stream | 13.1 ms | 82.2% | 16.7% | 1.0% | 435 |
| simdjson-jni-direct | 4.6 ms | 44.6% | 55.1% | 0.3% | 1240 |
Parsing stops being the bottleneck. Object mapping becomes it.