A fair benchmark for parsing streams of JSON documents, comparing simdjson — including the experimental parallel stream parser of PR #2788 — against Pison, on Pison's own corpus and queries.
Nothing is vendored: simdjson, Pison, and the performance-counter library are fetched and pinned by commit at configure time with CPM.
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/jsonbench --dataset twitter.ndjsonRequires a C++17 compiler and threads. Hardware performance counters need
kernel.perf_event_paranoid <= 1 or root. Export CPM_SOURCE_CACHE to share a
single dependency checkout across build trees.
jsonbench --dataset <file.ndjson> [options]
--query <name> twitter|bestbuy|google_map|nspl|walmart|wiki
(default: inferred from the filename)
--threads a,b,c thread counts to sweep (default: 1..hw, doubling)
--reps <n> repetitions per configuration, best wins (default 3)
--slice-kb <n> parse_many_parallel slice size (default 1024)
--sections <list> load,verify,single,scaling,e2e (default: all)
--single-record treat the input as one bulky JSON document
--verify check that the engines agree, then exit
--dump <n> print the first n extracted values from each engine
Output is one RESULT key=value ... line per measured configuration.
Both engines answer the same query and must produce the same answer. The benchmark checks the match count and an order-independent hash of the extracted values before reporting any timing, and refuses to claim agreement otherwise. Five of the six queries reproduce the match counts published in the Pison paper exactly:
| TT | BB | GMD | WM | WP | |
|---|---|---|---|---|---|
| matches | 300,270 | 459,332 | 1,716,752 | 288,391 | 15,603 |
Every phase is timed separately, so nothing hides:
| phase | what it measures |
|---|---|
load-published |
Pison's own RecordLoader::loadRecords |
load-reference |
the same required layout, written carefully, serial |
load-fast |
the same required layout, in parallel — the steelman |
boundary-only |
record boundaries with no copy: a lower bound, and what simdjson's dispatcher does |
index |
structural index only |
query |
index + JSONPath evaluation |
decode |
+ unescape strings and convert numbers |
e2e |
everything a caller actually pays, buffer in to values out |
Pison's record-table pass is included in its end-to-end numbers. It is
mandatory: its index construction derives its scan bound as length/32 while
sizing bitmaps at length/64, so a record not padded to a 64-byte multiple
writes past every bitmap. Reporting only its published loader would overstate
the gap, so load-fast gives Pison a parallel, carefully written implementation
of the pass its algorithm requires.
Pison is also given the smallest valid level_num per dataset. That is not
the query's depth: Pison writes at the record's true nesting level regardless of
how many levels it allocated, so level_num must cover the document. The minima
(2, 3, 8, 1, 2, 5) were determined empirically.
Hardware counters are collected for single-threaded configurations only.
perf_event_open without inheritance sees just the calling thread, and both
engines spawn workers internally, so a multi-threaded reading would be wrong
rather than noisy. Parallel runs report wall-clock throughput plus CPU seconds
per gigabyte from getrusage, which does aggregate all threads.
./datasets.sh obtains the whole corpus:
./datasets.sh --dir ~/jsonbenchIt downloads the six bulky records from the public collection the Pison and
cuJSON papers use, then derives the JSON-lines form of each with make_ndjson,
which minifies every element of the dataset's dominating array onto its own line
(tweets, data, products, items, items, items). Roughly 12 GB of disk
and a pip install gdown. The result is ~/jsonbench/ndjson/<dataset>.ndjson.
The collection publishes all six datasets as bulky records but only two of the six JSON-lines files, which is why the rest are derived. On the two published in both forms, the derived file has exactly the same record count as the published one — and five of the six queries then reproduce the paper's match counts exactly, which is the stronger check.
Two cautions, both learned the hard way:
- The bulky records are fetched by file id, not by pulling the whole
published folder. That folder also holds the scalability corpus, the cuDF
copies, and the
meta_jsonset — about 51 GB, eight times what the benchmark needs — and downloading all of it is what trips Google Drive's per-file quota. The fetch order is rotated per host so co-provisioned machines do not request the same file at the same moment. - Even so, provisioning several machines at once can exhaust the quota
(
Cannot retrieve the public link of the file); retrying does not help, it resets after roughly a day. Keep one machine as the corpus holder and copy from it instead:
./datasets.sh --dir ~/jsonbench --corpus-from holder:jsonbench/ndjsonA single file can also be converted directly:
./build/make_ndjson twitter_large_record.json twitter.ndjson tweetsReported because they affect anyone using these as a baseline.
Pison
SerialBitmapIterator::getValuereturns amalloc'd buffer on most paths but a string literal""on two, so thefree(value)idiom used throughout Pison's own examples is undefined behaviour (munmap_chunk: invalid pointer).ParallelBitmapConstructoraborts with heap corruption at exactlythread_num == 2on our inputs; 1 and ≥ 4 are fine.RecordLoader::loadRecordsaccumulates record offsets in anint, which overflows past 2 GB.- Records must be 64-byte aligned and 64-byte padded, or index construction writes out of bounds. Neither requirement is documented.
cuJSON's related_works/pison harnesses
google.cppruns the Best Buy query, not the Google Maps one.nspl.cppruns the Google Maps query with"text"misspelled"tex", so it matches nothing.- The JSON-lines harnesses hard-code
thread_num = 1, so the Pison figures reported against them are single-threaded.
We implement all queries from the specifications in Pison's paper rather than from these harnesses, which is why the match counts reproduce.
Apache 2.0. See LICENSE.