Skip to content

About

Reproducible benchmark of simdjson reached from Java through JNI, against Jackson: what the boundary actually costs.

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

simdjson-jni

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.

What is actually measured

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

The fairness contract

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.json and all 114,950 in marine_ik.json. No tolerance was needed anywhere.
  • twitter.json and twitterescaped.json produce different checksums. They are not the same document: the escaped variant has tweet IDs rounded at ~17 significant digits (505874924095815681 vs 505874924095815700), 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.

Data

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 by tools/make_feed.py from vehicles.csv. A pure format conversion — one JSON object per line, per-column type inference, empty CSV cells become null. Nothing is invented; where the EPA data literally contains the string "NA" (3 rows in cylinders, 2 in displ) the column stays a string column rather than being silently nulled.

Reproducing

export JAVA_HOME=$(/usr/libexec/java_home -v 21)
bash scripts/run_bench.sh

That 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.

What this benchmark does NOT show

  • 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 arm64 implementation 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 the DirectByteBuffer are 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.time are run-scoped, not per-operation, and depend on the pinned 2 GB heap. gc.alloc.rate.norm is 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.

Layout

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.

Measured results

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.

Throughput (MB/s, 99.9% CI half-width)

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.

Boundary cost

  • 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.

Allocation (Java heap per parse, corpus mean)

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

Per-batch latency (microseconds)

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.

Pipeline stage shares

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.

About

Reproducible benchmark of simdjson reached from Java through JNI, against Jackson: what the boundary actually costs.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages