From 187aefa78dee0327c0448d2cfa9d1dadd70a16a0 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Thu, 30 Jul 2026 18:27:54 -0400 Subject: [PATCH 01/11] Own the parallel stream driver instead of patching simdjson The parallel decomposition was carried as an experimental header inside simdjson (PR #2788). It does not need to be there: it is a slicing rule plus a thread pool over the public iterate_many API. Moving it here makes it something a reader can lift into their own pipeline, and lets simdjson carry only the primitives that make it fast. src/parallel_stream.h is that driver. What simdjson keeps is the delimiter-based document skip, which the driver opts into by asking for stream_format::newline_delimited -- exactly the guarantee our slicing rule already relies on. CMake now pins released simdjson and detects newline_delimited rather than requiring it, so the benchmark still builds against a simdjson without the pending change; it just uses whitespace_delimited and forgoes the skip. --- CMakeLists.txt | 30 +++++-- src/main.cpp | 15 +++- src/parallel_stream.h | 168 ++++++++++++++++++++++++++++++++++++++++ src/simdjson_engine.cpp | 15 +++- 4 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 src/parallel_stream.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f5c403..9ee8630 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,14 +28,14 @@ find_package(Threads REQUIRED) list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/cmake) include(get_cpm) -# --- simdjson: the PR #2788 branch, pinned by commit ----------------------- -# parse_many_parallel lives in include/simdjson/ondemand_parallel.h on this -# branch. Pinning by SHA keeps the paper's numbers reproducible even if the -# branch is rebased or merged. +# --- simdjson -------------------------------------------------------------- +# The parallel stream driver is ours (src/parallel_stream.h), so a released +# simdjson suffices. Pinned by SHA to keep the paper reproducible. + CPMAddPackage( NAME simdjson GITHUB_REPOSITORY simdjson/simdjson - GIT_TAG 88186d59 # PR #2788 + GIT_TAG 93fce66a # master OPTIONS "SIMDJSON_DEVELOPER_MODE OFF" "BUILD_SHARED_LIBS OFF") # --- lemire/counters: hardware performance counters (header only) ---------- @@ -69,6 +69,25 @@ target_compile_options(pison PUBLIC -include cstdint) target_compile_features(pison PUBLIC cxx_std_11) set_target_properties(pison PROPERTIES POSITION_INDEPENDENT_CODE ON) +# The parallel stream driver lives in this repository (src/parallel_stream.h), +# not in simdjson. It goes faster when simdjson can skip the tail of a partially +# read document, which needs stream_format::newline_delimited; detect it rather +# than require it, so the benchmark still builds against released simdjson. +if(EXISTS ${simdjson_SOURCE_DIR}/include/simdjson/base.h) + file(READ ${simdjson_SOURCE_DIR}/include/simdjson/base.h SIMDJSON_BASE_H) + string(FIND "${SIMDJSON_BASE_H}" "newline_delimited" NEWLINE_DELIMITED_POS) +else() + set(NEWLINE_DELIMITED_POS -1) +endif() +if(NEWLINE_DELIMITED_POS GREATER -1) + set(NEWLINE_DELIMITED 1) + message(STATUS "simdjson has stream_format::newline_delimited") +else() + set(NEWLINE_DELIMITED 0) + message(STATUS "simdjson lacks stream_format::newline_delimited; " + "the parallel engine will use whitespace_delimited") +endif() + # --- conventional DOM parsers ---------------------------------------------- # Baselines: what a practitioner uses today. Each is optional; a library that # does not resolve drops its engine from the run rather than breaking the build. @@ -158,6 +177,7 @@ add_executable(jsonbench ${DOM_SOURCES}) target_include_directories(jsonbench PRIVATE src) target_compile_definitions(jsonbench PRIVATE + JSONBENCH_HAVE_NEWLINE_DELIMITED=${NEWLINE_DELIMITED} JSONBENCH_HAVE_YYJSON=${HAVE_YYJSON} JSONBENCH_HAVE_RAPIDJSON=${HAVE_RAPIDJSON} JSONBENCH_HAVE_BOOST_JSON=${HAVE_BOOST_JSON} diff --git a/src/main.cpp b/src/main.cpp index ea9b4a7..50df9d1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -54,10 +54,17 @@ struct options { // scripts narrow this so a slice-size or thread-count study does not re-run // the loader and agreement work every time. std::string sections = "load,verify,single,scaling,e2e"; + // Restrict the run to one engine. Aggregate profilers (perf stat -a) cannot + // attribute counters to an engine when several run in the same process, so + // isolating one is the only way to ask "what is *this* engine waiting on". + std::string only_engine; bool wants(const char *s) const { return sections.find(s) != std::string::npos; } + bool wants_engine(const char *e) const { + return only_engine.empty() || only_engine == e; + } }; void usage() { @@ -76,7 +83,9 @@ void usage() { " --dump print the first n extracted values from each\n" " engine side by side, then exit\n" " --sections comma list of load,verify,single,scaling,e2e\n" - " (default: all)\n"); + " (default: all)\n" + " --engine-only run only this engine (e.g. simdjson-parallel,\n" + " yyjson-parallel); for profiling one engine alone\n"); } bool parse_args(int argc, char **argv, options &o) { @@ -95,6 +104,7 @@ bool parse_args(int argc, char **argv, options &o) { else if (a == "--verify") { o.verify_only = true; } else if (a == "--dump") { o.dump = strtoull(next().c_str(), nullptr, 10); } else if (a == "--sections") { o.sections = next(); } + else if (a == "--engine-only") { o.only_engine = next(); } else if (a == "--levels") { o.levels = atoi(next().c_str()); } else if (a == "--threads") { std::stringstream ss(next()); @@ -442,6 +452,7 @@ int main(int argc, char **argv) { // Thread scaling. // ----------------------------------------------------------------------- for (size_t t : o.wants("scaling") ? o.threads : std::vector{}) { + if (o.wants_engine("pison-parallel")) for (const auto &ph : pison_phases) { auto s = measure_parallel(o.reps, [&] { pison::run_stream(ptext, tbl, q, ph.w, levels, t); @@ -450,6 +461,7 @@ int main(int argc, char **argv) { emit("pison-parallel", ph.phase, pison::workload_name(ph.w), t, o, label, bytes, docs, s, e); } + if (o.wants_engine("simdjson-parallel")) for (const auto &ph : sj_phases) { auto s = measure_parallel(o.reps, [&] { sj::run_parallel(data, bytes, q, ph.w, t, o.slice_kb << 10); @@ -464,6 +476,7 @@ int main(int argc, char **argv) { // how much from on-demand parsing. for (auto lib : kDomLibraries) { if (!dom::available(lib)) { continue; } + if (!o.wants_engine(dom::engine_name(lib, true))) { continue; } auto ds = measure_parallel(o.reps, [&] { dom::run_parallel(lib, data, bytes, q, t, o.slice_kb << 10, dom_longest); }); diff --git a/src/parallel_stream.h b/src/parallel_stream.h new file mode 100644 index 0000000..a95b13e --- /dev/null +++ b/src/parallel_stream.h @@ -0,0 +1,168 @@ +// Parse a stream of JSON documents across many threads, on top of simdjson's +// on-demand interface. +// +// This lived in simdjson as an experimental header (PR #2788); it belongs in +// user code instead. Nothing here needs to be inside the library -- it is a +// slicing rule plus a thread pool over the public `iterate_many` API -- and +// keeping it out means callers can adapt the decomposition to their own +// pipeline rather than accept ours. +// +// Design. Each worker claims a byte range from one atomic counter and snaps +// both ends forward to the next delimiter, so slices abut, never split a +// document, and are computed with no coordination beyond the counter. Each +// worker owns its parser and its output vector, so the hot path is lock-free. +// Results come back as one vector per worker ("shards"): values keep their +// order within a shard, but shards interleave with respect to the input. +// +// Formats. The slicing rule needs a delimiter that cannot occur inside a JSON +// value: a line feed for NDJSON, or a record separator (0x1E) for RFC 7464. +// Comma-delimited input is not supported, because a top-level comma can only be +// found by a serial structural scan. +// +// stream_format::newline_delimited, where available, additionally lets simdjson +// skip the tail of a partially read document without walking its structural +// characters. +#ifndef JSONBENCH_PARALLEL_STREAM_H +#define JSONBENCH_PARALLEL_STREAM_H + +#include "simdjson.h" + +#include +#include +#include +#include +#include + +namespace jsonbench { +namespace parallel { + +using simdjson::error_code; +using simdjson::stream_format; +using simdjson::SUCCESS; + +struct options { + size_t threads = 0; // 0 = hardware_concurrency() + size_t slice_bytes = 256u << 10; + stream_format format = stream_format::whitespace_delimited; +}; + +template class result { +public: + const std::vector> &shards() const noexcept { return _shards; } + size_t size() const noexcept { + size_t n = 0; + for (const auto &s : _shards) { n += s.size(); } + return n; + } + size_t errors() const noexcept { return _errors; } + error_code first_error() const noexcept { return _first_error; } + + std::vector> _shards{}; + size_t _errors{0}; + error_code _first_error{SUCCESS}; +}; + +namespace internal { + +// Snap `want` forward to the next boundary in [0, hi). Depends only on `want`, +// which is what lets each worker compute its own slice: the end of one slice +// and the start of the next are the same call. +inline size_t snap(const char *data, size_t hi, size_t want, + stream_format format) { + if (want >= hi) { return hi; } + if (format == stream_format::json_sequence) { + const void *rs = std::memchr(data + want, 0x1e, hi - want); + return rs ? size_t(static_cast(rs) - data) : hi; + } + const void *nl = std::memchr(data + want, '\n', hi - want); + return nl ? size_t(static_cast(nl) - data) + 1 : hi; +} + +inline bool splittable(stream_format f) { + return f == stream_format::whitespace_delimited || + f == stream_format::json_sequence +#if JSONBENCH_HAVE_NEWLINE_DELIMITED + || f == stream_format::newline_delimited +#endif + ; +} + +} // namespace internal + +// Extract a value of type T from every document in `json`. +// +// `extract(doc, out)` returns SUCCESS to keep `out`, or an error to skip the +// document. Errors are counted, not fatal. +template +result parse_many(simdjson::padded_string_view json, F &&extract, + options opt = {}) { + result out; + const char *const data = json.data(); + const size_t length = json.size(); + + size_t workers = opt.threads; + if (workers == 0) { + unsigned hw = std::thread::hardware_concurrency(); + workers = hw > 1 ? size_t(hw) : 1; + } + if (!internal::splittable(opt.format)) { workers = 1; } + const size_t slice_bytes = opt.slice_bytes ? opt.slice_bytes : (256u << 10); + + out._shards.resize(workers); + std::vector errors(workers, 0); + std::vector first(workers, SUCCESS); + std::atomic cursor{0}; + + std::vector pool; + pool.reserve(workers); + for (size_t w = 0; w < workers; w++) { + pool.emplace_back([&, w] { + simdjson::ondemand::parser parser; + parser.threaded = false; + std::vector &shard = out._shards[w]; + + for (;;) { + const size_t raw = cursor.fetch_add(slice_bytes, + std::memory_order_relaxed); + if (raw >= length) { break; } + const size_t begin = + raw == 0 ? 0 : internal::snap(data, length, raw, opt.format); + const size_t end = + internal::snap(data, length, raw + slice_bytes, opt.format); + if (begin >= end) { continue; } // a long document covered by an earlier claim + + simdjson::ondemand::document_stream stream; + if (auto e = parser + .iterate_many(data + begin, end - begin, end - begin, + opt.format) + .get(stream)) { + errors[w]++; + if (!first[w]) { first[w] = e; } + continue; + } + for (auto it = stream.begin(); it != stream.end(); ++it) { + auto doc = *it; + T value; + if (auto e = extract(doc, value)) { + errors[w]++; + if (!first[w]) { first[w] = e; } + continue; + } + shard.push_back(std::move(value)); + } + } + }); + } + for (auto &t : pool) { t.join(); } + + for (size_t w = 0; w < workers; w++) { + out._errors += errors[w]; + if (!out._first_error) { out._first_error = first[w]; } + } + return out; +} + +} // namespace parallel +} // namespace jsonbench + +#endif diff --git a/src/simdjson_engine.cpp b/src/simdjson_engine.cpp index 48e0ca7..ebe2d84 100644 --- a/src/simdjson_engine.cpp +++ b/src/simdjson_engine.cpp @@ -1,5 +1,6 @@ #include "simdjson_engine.h" +#include "parallel_stream.h" #include "simdjson.h" #include @@ -223,17 +224,25 @@ extraction run_serial(const char *data, size_t size, query_id q, workload w, extraction run_parallel(const char *data, size_t size, query_id q, workload w, size_t threads, size_t slice_bytes) { - experimental::parallel_stream_options options; + parallel::options options; options.threads = threads; options.slice_bytes = slice_bytes; +#if JSONBENCH_HAVE_NEWLINE_DELIMITED + // Our corpus is strictly one document per line, which lets simdjson skip the + // tail of a partially read document instead of walking it. + options.format = stream_format::newline_delimited; +#else options.format = stream_format::whitespace_delimited; +#endif padded_string_view json(data, size, size + SIMDJSON_PADDING); - auto result = experimental::parse_many_parallel( + auto result = parallel::parse_many( json, [q, w](auto doc, extraction &out) { return dispatch(doc, out, q, w); }, options); extraction total; - result.for_each([&](const extraction &e) { total.merge(e); }); + for (const auto &shard : result.shards()) { + for (const extraction &e : shard) { total.merge(e); } + } return total; } From 4548484c692fd6f9acf2d3dd0732d12419fb9114 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Thu, 30 Jul 2026 23:19:43 -0400 Subject: [PATCH 02/11] Pin simdjson to the PR #2803 commit --- CMakeLists.txt | 16 +++++++---- src/parallel_stream.h | 65 ++++++++++++++++++++++++------------------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ee8630..7b3dcf5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,7 @@ include(get_cpm) CPMAddPackage( NAME simdjson GITHUB_REPOSITORY simdjson/simdjson - GIT_TAG 93fce66a # master + GIT_TAG 4ee79f7f # PR #2803 OPTIONS "SIMDJSON_DEVELOPER_MODE OFF" "BUILD_SHARED_LIBS OFF") # --- lemire/counters: hardware performance counters (header only) ---------- @@ -73,19 +73,23 @@ set_target_properties(pison PROPERTIES POSITION_INDEPENDENT_CODE ON) # not in simdjson. It goes faster when simdjson can skip the tail of a partially # read document, which needs stream_format::newline_delimited; detect it rather # than require it, so the benchmark still builds against released simdjson. +set(NEWLINE_DELIMITED_POS -1) +set(SLICE_AT_POS -1) if(EXISTS ${simdjson_SOURCE_DIR}/include/simdjson/base.h) file(READ ${simdjson_SOURCE_DIR}/include/simdjson/base.h SIMDJSON_BASE_H) string(FIND "${SIMDJSON_BASE_H}" "newline_delimited" NEWLINE_DELIMITED_POS) -else() - set(NEWLINE_DELIMITED_POS -1) endif() -if(NEWLINE_DELIMITED_POS GREATER -1) +if(EXISTS ${simdjson_SOURCE_DIR}/include/simdjson/padded_string_view.h) + file(READ ${simdjson_SOURCE_DIR}/include/simdjson/padded_string_view.h SIMDJSON_PSV_H) + string(FIND "${SIMDJSON_PSV_H}" "slice_at" SLICE_AT_POS) +endif() +if(NEWLINE_DELIMITED_POS GREATER -1 AND SLICE_AT_POS GREATER -1) set(NEWLINE_DELIMITED 1) message(STATUS "simdjson has stream_format::newline_delimited") else() set(NEWLINE_DELIMITED 0) - message(STATUS "simdjson lacks stream_format::newline_delimited; " - "the parallel engine will use whitespace_delimited") + message(STATUS "simdjson lacks newline_delimited/slice_at; the parallel " + "engine falls back to its own slicing") endif() # --- conventional DOM parsers ---------------------------------------------- diff --git a/src/parallel_stream.h b/src/parallel_stream.h index a95b13e..cd14509 100644 --- a/src/parallel_stream.h +++ b/src/parallel_stream.h @@ -7,9 +7,9 @@ // keeping it out means callers can adapt the decomposition to their own // pipeline rather than accept ours. // -// Design. Each worker claims a byte range from one atomic counter and snaps -// both ends forward to the next delimiter, so slices abut, never split a -// document, and are computed with no coordination beyond the counter. Each +// Design. Each worker claims a slice index from one atomic counter and calls +// simdjson::slice_at, which snaps both ends to a delimiter so slices abut and +// never split a document. Each // worker owns its parser and its output vector, so the hot path is lock-free. // Results come back as one vector per worker ("shards"): values keep their // order within a shard, but shards interleave with respect to the input. @@ -64,20 +64,6 @@ template class result { namespace internal { -// Snap `want` forward to the next boundary in [0, hi). Depends only on `want`, -// which is what lets each worker compute its own slice: the end of one slice -// and the start of the next are the same call. -inline size_t snap(const char *data, size_t hi, size_t want, - stream_format format) { - if (want >= hi) { return hi; } - if (format == stream_format::json_sequence) { - const void *rs = std::memchr(data + want, 0x1e, hi - want); - return rs ? size_t(static_cast(rs) - data) : hi; - } - const void *nl = std::memchr(data + want, '\n', hi - want); - return nl ? size_t(static_cast(nl) - data) + 1 : hi; -} - inline bool splittable(stream_format f) { return f == stream_format::whitespace_delimited || f == stream_format::json_sequence @@ -87,6 +73,33 @@ inline bool splittable(stream_format f) { ; } +inline char delimiter_for(stream_format f) { + return f == stream_format::json_sequence ? char(0x1e) : '\n'; +} + +#if JSONBENCH_HAVE_NEWLINE_DELIMITED +using simdjson::slice_at; +#else +// simdjson::slice_at is not in released simdjson yet; same contract. +inline simdjson::padded_string_view slice_at(simdjson::padded_string_view data, + char delimiter, size_t block_size, + size_t index) noexcept { + if (block_size == 0 || index > data.size() / block_size) { return {}; } + const size_t raw_begin = index * block_size; + if (raw_begin >= data.size()) { return {}; } + auto snap = [&](size_t want) -> size_t { + if (want >= data.size()) { return data.size(); } + const void *p = std::memchr(data.data() + want, delimiter, data.size() - want); + return p ? size_t(static_cast(p) - data.data()) + 1 : data.size(); + }; + const size_t begin = (raw_begin == 0) ? 0 : snap(raw_begin); + const size_t end = snap(raw_begin + block_size); + if (begin >= end) { return {}; } + return simdjson::padded_string_view(data.data() + begin, end - begin, + data.capacity() - begin); +} +#endif + } // namespace internal // Extract a value of type T from every document in `json`. @@ -97,8 +110,8 @@ template result parse_many(simdjson::padded_string_view json, F &&extract, options opt = {}) { result out; - const char *const data = json.data(); const size_t length = json.size(); + const char delimiter = internal::delimiter_for(opt.format); size_t workers = opt.threads; if (workers == 0) { @@ -122,19 +135,13 @@ result parse_many(simdjson::padded_string_view json, F &&extract, std::vector &shard = out._shards[w]; for (;;) { - const size_t raw = cursor.fetch_add(slice_bytes, - std::memory_order_relaxed); - if (raw >= length) { break; } - const size_t begin = - raw == 0 ? 0 : internal::snap(data, length, raw, opt.format); - const size_t end = - internal::snap(data, length, raw + slice_bytes, opt.format); - if (begin >= end) { continue; } // a long document covered by an earlier claim + const size_t i = cursor.fetch_add(1, std::memory_order_relaxed); + if (i * slice_bytes >= length) { break; } + auto piece = internal::slice_at(json, delimiter, slice_bytes, i); + if (piece.empty()) { continue; } simdjson::ondemand::document_stream stream; - if (auto e = parser - .iterate_many(data + begin, end - begin, end - begin, - opt.format) + if (auto e = parser.iterate_many(piece, piece.size(), opt.format) .get(stream)) { errors[w]++; if (!first[w]) { first[w] = e; } From 0b326f2a4ff9cfcb1b18064a03b95ab80086b569 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Thu, 30 Jul 2026 23:27:42 -0400 Subject: [PATCH 03/11] Serial engine asks for newline_delimited too run_serial produces every engine=simdjson row, and it was still asking for whitespace_delimited, so it took none of the delimiter skip. Pison cannot parse without its record table, which is the same line structure, and we build and hand it that; withholding it from simdjson is not the neutral choice. --- src/simdjson_engine.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/simdjson_engine.cpp b/src/simdjson_engine.cpp index ebe2d84..efccc3f 100644 --- a/src/simdjson_engine.cpp +++ b/src/simdjson_engine.cpp @@ -207,9 +207,16 @@ extraction run_serial(const char *data, size_t size, query_id q, workload w, parser.threaded = threaded; extraction total; ondemand::document_stream stream; + // The corpus is one document per line, the same structure Pison is handed as + // its record table, so we tell simdjson too. if (!parser .iterate_many(data, size, batch_bytes, - stream_format::whitespace_delimited) +#if JSONBENCH_HAVE_NEWLINE_DELIMITED + stream_format::newline_delimited +#else + stream_format::whitespace_delimited +#endif + ) .get(stream)) { for (auto it = stream.begin(); it != stream.end(); ++it) { auto doc = *it; From c605a6631014e568cd86e5f19519199b8664d0ff Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Fri, 31 Jul 2026 13:37:15 -0400 Subject: [PATCH 04/11] Add --impl to force a simdjson kernel Runtime dispatch picks the widest supported kernel, which is what you want in production and not what you want when asking whether 512-bit code is the right choice at two threads per core. --- src/main.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 50df9d1..89d79d3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,8 @@ #include "pison_engine.h" #include "simdjson_engine.h" +#include "simdjson.h" + #include #include #include @@ -58,6 +60,8 @@ struct options { // attribute counters to an engine when several run in the same process, so // isolating one is the only way to ask "what is *this* engine waiting on". std::string only_engine; + // Force a simdjson kernel (haswell, icelake, ...) instead of runtime dispatch. + std::string impl; bool wants(const char *s) const { return sections.find(s) != std::string::npos; @@ -85,7 +89,8 @@ void usage() { " --sections comma list of load,verify,single,scaling,e2e\n" " (default: all)\n" " --engine-only run only this engine (e.g. simdjson-parallel,\n" - " yyjson-parallel); for profiling one engine alone\n"); + " yyjson-parallel); for profiling one engine alone\n" + " --impl force a simdjson kernel (haswell, icelake, ...)\n"); } bool parse_args(int argc, char **argv, options &o) { @@ -105,6 +110,7 @@ bool parse_args(int argc, char **argv, options &o) { else if (a == "--dump") { o.dump = strtoull(next().c_str(), nullptr, 10); } else if (a == "--sections") { o.sections = next(); } else if (a == "--engine-only") { o.only_engine = next(); } + else if (a == "--impl") { o.impl = next(); } else if (a == "--levels") { o.levels = atoi(next().c_str()); } else if (a == "--threads") { std::stringstream ss(next()); @@ -167,6 +173,16 @@ int main(int argc, char **argv) { options o; if (!parse_args(argc, argv, o)) { usage(); return 1; } + if (!o.impl.empty()) { + auto wanted = simdjson::get_available_implementations()[o.impl]; + if (wanted == nullptr || !wanted->supported_by_runtime_system()) { + std::fprintf(stderr, "simdjson implementation unavailable: %s\n", + o.impl.c_str()); + return 1; + } + simdjson::get_active_implementation() = wanted; + } + query_id q; if (!o.query_name.empty()) { if (!query_from_name(o.query_name, q)) { From e85f81a472e9d824ffc569f9dd2d86a617144239 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 1 Aug 2026 00:32:03 -0400 Subject: [PATCH 05/11] Assign each worker a contiguous run of slices Workers claimed the next free slice from a shared counter, so a worker's consecutive regions sat threads*slice apart -- tens of megabytes at high thread counts. Giving each worker one contiguous run instead removes the small-slice collapse and is never slower on any dataset we measure. Default slice size is now 64 KiB, the size whose worst case over six datasets is the least bad, and per-worker parser state drops accordingly. Add --assign to select either policy, and record the spread between the slowest and fastest repetition so measurement dispersion is reportable. --- src/dom_engine.h | 7 +++++++ src/dom_parallel.h | 14 ++++++++++++-- src/main.cpp | 17 ++++++++++++----- src/metrics.h | 11 +++++++++++ src/parallel_stream.h | 18 ++++++++++++++++-- src/simdjson_engine.cpp | 4 +++- src/simdjson_engine.h | 3 ++- 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/dom_engine.h b/src/dom_engine.h index 42ff00d..cb5dff7 100644 --- a/src/dom_engine.h +++ b/src/dom_engine.h @@ -47,6 +47,13 @@ extraction run_serial(library lib, const char *data, size_t size, query_id q, extraction run_parallel(library lib, const char *data, size_t size, query_id q, size_t threads, size_t slice_bytes, size_t longest); +// Mirrors parallel::options::static_partition for the DOM drivers, which share +// run_sliced rather than a per-call options struct. Set before a run. +inline bool &static_partition_flag() { + static bool value = true; + return value; +} + } // namespace dom } // namespace jsonbench diff --git a/src/dom_parallel.h b/src/dom_parallel.h index 03f54b9..f5a5ea8 100644 --- a/src/dom_parallel.h +++ b/src/dom_parallel.h @@ -12,6 +12,7 @@ #define JSONBENCH_DOM_PARALLEL_H #include "common.h" +#include "dom_engine.h" #include "dom_queries.h" #include @@ -41,6 +42,8 @@ extraction run_sliced(const char *data, size_t length, size_t threads, if (threads == 0) { threads = 1; } if (slice_bytes == 0) { slice_bytes = 256 * 1024; } std::atomic cursor{0}; + const bool stat = static_partition_flag(); + const size_t slices = (length + slice_bytes - 1) / slice_bytes; std::vector shards(threads); std::vector workers; workers.reserve(threads); @@ -48,9 +51,16 @@ extraction run_sliced(const char *data, size_t length, size_t threads, workers.emplace_back([&, i] { auto parse_slice = make_worker(); extraction &mine = shards[i]; + size_t next = slices * i / threads; + const size_t last = slices * (i + 1) / threads; for (;;) { - const size_t raw = cursor.fetch_add(slice_bytes, - std::memory_order_relaxed); + size_t raw; + if (stat) { + if (next >= last) { break; } + raw = next++ * slice_bytes; + } else { + raw = cursor.fetch_add(slice_bytes, std::memory_order_relaxed); + } if (raw >= length) { break; } const size_t begin = (raw == 0) ? 0 : snap_to_newline(data, length, raw); const size_t end = snap_to_newline(data, length, raw + slice_bytes); diff --git a/src/main.cpp b/src/main.cpp index 89d79d3..0e4682f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -46,8 +46,9 @@ struct options { std::string query_name; std::vector threads; int reps = 3; - size_t slice_kb = 1024; // parse_many_parallel slice size + size_t slice_kb = 64; // parse_many_parallel slice size size_t batch_mb = 16; // iterate_many batch for the built-in 2-thread mode + bool static_partition = true; // --assign dynamic to override bool single_record = false; bool verify_only = false; size_t dump = 0; @@ -80,7 +81,8 @@ void usage() { " (default: inferred from the filename)\n" " --threads a,b,c thread counts to sweep (default: 1..hw, doubling)\n" " --reps repetitions per configuration, best wins (default 3)\n" - " --slice-kb parse_many_parallel slice size (default 1024)\n" + " --slice-kb parse_many_parallel slice size (default 64)\n" + " --assign slice assignment: static (default) or dynamic\n" " --batch-mb iterate_many batch size (default 16)\n" " --single-record treat the input as one bulky JSON document\n" " --verify check that the engines agree, then exit\n" @@ -105,6 +107,7 @@ bool parse_args(int argc, char **argv, options &o) { else if (a == "--reps") { o.reps = atoi(next().c_str()); } else if (a == "--slice-kb") { o.slice_kb = strtoull(next().c_str(), nullptr, 10); } else if (a == "--batch-mb") { o.batch_mb = strtoull(next().c_str(), nullptr, 10); } + else if (a == "--assign") { o.static_partition = (next() == "static"); } else if (a == "--single-record") { o.single_record = true; } else if (a == "--verify") { o.verify_only = true; } else if (a == "--dump") { o.dump = strtoull(next().c_str(), nullptr, 10); } @@ -163,7 +166,8 @@ void emit(const char *engine, const char *phase, const char *workload_name, s.branch_misses / (double(bytes) / 1024.0), s.cache_misses / (double(bytes) / 1024.0)); } - std::printf(" reps=%d\n", o.reps); + std::printf(" spread_pct=%.2f reps=%d", s.spread_pct(), o.reps); + std::printf("\n"); std::fflush(stdout); } @@ -480,9 +484,11 @@ int main(int argc, char **argv) { if (o.wants_engine("simdjson-parallel")) for (const auto &ph : sj_phases) { auto s = measure_parallel(o.reps, [&] { - sj::run_parallel(data, bytes, q, ph.w, t, o.slice_kb << 10); + sj::run_parallel(data, bytes, q, ph.w, t, o.slice_kb << 10, + o.static_partition); }); - extraction e = sj::run_parallel(data, bytes, q, ph.w, t, o.slice_kb << 10); + extraction e = sj::run_parallel(data, bytes, q, ph.w, t, o.slice_kb << 10, + o.static_partition); emit("simdjson-parallel", ph.phase, sj::workload_name(ph.w), t, o, label, bytes, docs, s, e); } @@ -490,6 +496,7 @@ int main(int argc, char **argv) { // on-demand one. Nothing in the decomposition knows what parses a document, // so this measures how much of our throughput comes from the slicing and // how much from on-demand parsing. + dom::static_partition_flag() = o.static_partition; for (auto lib : kDomLibraries) { if (!dom::available(lib)) { continue; } if (!o.wants_engine(dom::engine_name(lib, true))) { continue; } diff --git a/src/metrics.h b/src/metrics.h index 6a1034e..66c87ff 100644 --- a/src/metrics.h +++ b/src/metrics.h @@ -38,12 +38,21 @@ inline double cpu_seconds() { // One measured configuration. struct measurement { double seconds = 0; // wall clock, best of the repetitions + double worst_seconds = 0; // wall clock of the slowest repetition double cpu_seconds = 0; // CPU time of that same repetition, all threads double instructions = 0; // 0 when counters are unavailable or meaningless double cycles = 0; double branch_misses = 0; double cache_misses = 0; bool has_counters = false; + + // Spread between the slowest and fastest repetition, relative to the + // fastest, in percent. Reporting only the best hides how repeatable a + // configuration was, and repeatability varies enormously between machines. + double spread_pct() const { + if (seconds <= 0 || worst_seconds <= 0) { return 0; } + return 100.0 * (worst_seconds - seconds) / seconds; + } }; // Run `fn` `reps` times and keep the fastest repetition, with per-thread @@ -60,6 +69,7 @@ template measurement measure_single(int reps, Fn &&fn) { fn(); counters::event_count c = collector.end(); double cpu1 = cpu_seconds(); + if (c.elapsed_sec() > best.worst_seconds) { best.worst_seconds = c.elapsed_sec(); } if (c.elapsed_sec() < best.seconds) { best.seconds = c.elapsed_sec(); best.cpu_seconds = cpu1 - cpu0; @@ -87,6 +97,7 @@ template measurement measure_parallel(int reps, Fn &&fn) { auto t1 = std::chrono::steady_clock::now(); double cpu1 = cpu_seconds(); double s = std::chrono::duration(t1 - t0).count(); + if (s > best.worst_seconds) { best.worst_seconds = s; } if (s < best.seconds) { best.seconds = s; best.cpu_seconds = cpu1 - cpu0; diff --git a/src/parallel_stream.h b/src/parallel_stream.h index cd14509..6e75fb2 100644 --- a/src/parallel_stream.h +++ b/src/parallel_stream.h @@ -42,8 +42,12 @@ using simdjson::SUCCESS; struct options { size_t threads = 0; // 0 = hardware_concurrency() - size_t slice_bytes = 256u << 10; + size_t slice_bytes = 64u << 10; stream_format format = stream_format::whitespace_delimited; + // Each worker owns one contiguous run of slices. The alternative, claiming + // the next free slice from a shared counter, scatters a worker's regions + // across the input and costs up to 1.55x; it is kept only for reproduction. + bool static_partition = true; }; template class result { @@ -134,8 +138,18 @@ result parse_many(simdjson::padded_string_view json, F &&extract, parser.threaded = false; std::vector &shard = out._shards[w]; + const size_t slices = (length + slice_bytes - 1) / slice_bytes; + size_t next = slices * w / workers; + const size_t last = slices * (w + 1) / workers; + for (;;) { - const size_t i = cursor.fetch_add(1, std::memory_order_relaxed); + size_t i; + if (opt.static_partition) { + if (next >= last) { break; } + i = next++; + } else { + i = cursor.fetch_add(1, std::memory_order_relaxed); + } if (i * slice_bytes >= length) { break; } auto piece = internal::slice_at(json, delimiter, slice_bytes, i); if (piece.empty()) { continue; } diff --git a/src/simdjson_engine.cpp b/src/simdjson_engine.cpp index efccc3f..9018e49 100644 --- a/src/simdjson_engine.cpp +++ b/src/simdjson_engine.cpp @@ -230,10 +230,12 @@ extraction run_serial(const char *data, size_t size, query_id q, workload w, } extraction run_parallel(const char *data, size_t size, query_id q, workload w, - size_t threads, size_t slice_bytes) { + size_t threads, size_t slice_bytes, + bool static_partition) { parallel::options options; options.threads = threads; options.slice_bytes = slice_bytes; + options.static_partition = static_partition; #if JSONBENCH_HAVE_NEWLINE_DELIMITED // Our corpus is strictly one document per line, which lets simdjson skip the // tail of a partially read document instead of walking it. diff --git a/src/simdjson_engine.h b/src/simdjson_engine.h index ed0dc73..d4f525b 100644 --- a/src/simdjson_engine.h +++ b/src/simdjson_engine.h @@ -33,7 +33,8 @@ extraction run_serial(const char *data, size_t size, query_id q, workload w, // experimental::parse_many_parallel from simdjson PR #2788. extraction run_parallel(const char *data, size_t size, query_id q, workload w, - size_t threads, size_t slice_bytes); + size_t threads, size_t slice_bytes, + bool static_partition = false); const char *implementation_name(); From 0b46bec73f350e1fd30399f069eb6581edc974b5 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Fri, 28 Aug 2026 13:11:57 -0400 Subject: [PATCH 06/11] Pin simdjson to the merged commit, not the PR branch The pin 4ee79f7f is a commit on the PR #2803 branch, which is not reachable from master now that the pull request has been merged. A checkout with a cold CPM cache therefore cannot configure at all: CMake Error ... Failed to checkout tag: '4ee79f7f' Our own builds only worked because the commit was already sitting in a warm CPM_SOURCE_CACHE, so the breakage was invisible from here. 9b89b82d is the same change as merged to master, and it resolves from a plain clone. Verified on the reference machine (2x Xeon Gold 6548N) from an empty cache: the build succeeds, every engine-agreement check passes, and the six-dataset matrix reproduces the previous numbers within run-to-run variance. Instructions per byte are unchanged except on Best Buy, where the merged tree retires 3.3% fewer. Claude-Session: https://claude.ai/code/session_011wn2H8y2i839FRVw5Jugoi --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b3dcf5..acc6b11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -35,7 +35,7 @@ include(get_cpm) CPMAddPackage( NAME simdjson GITHUB_REPOSITORY simdjson/simdjson - GIT_TAG 4ee79f7f # PR #2803 + GIT_TAG 9b89b82d # PR #2803, as merged to master OPTIONS "SIMDJSON_DEVELOPER_MODE OFF" "BUILD_SHARED_LIBS OFF") # --- lemire/counters: hardware performance counters (header only) ---------- From 3ac20c1d1775f1e3b4799d812c8c97db0583ccf4 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 29 Aug 2026 16:54:58 -0400 Subject: [PATCH 07/11] Pin simdjson to master HEAD The pin 9b89b82d was the commit that merged PR #2803, and it sat 19 commits behind master. The comment above it claimed "a released simdjson suffices", which is not true in the sense that matters: simdjson cuts releases from a 4.6.x branch, so neither stream_format::newline_delimited nor simdjson::slice_at has shipped in one. The newest tag reachable from master is v4.6.1 while the release line is out at v4.6.9, and neither v4.6.1 nor v4.6.9 contains either symbol. A release does still build -- the configure-time probe detects their absence and the driver falls back to its own memchr slicing -- but that is the path every release user takes, not a corner case, and it measures a different thing. Pinning master says so honestly. 3839ac68 is master as of 2026-08-26. It carries 19 commits since the old pin, including #2809, which puts state-based container locking on on-demand object iteration and so could plausibly move the numbers. Verified on the reference machine (2x Xeon Gold 6548N) from a cold pin: the configure resolves from a plain clone, the probe still finds newline_delimited, the build is clean, and all six datasets pass the agreement check with the published match counts unchanged -- twitter 300,270, bestbuy 459,332, google_map 1,716,752, walmart 288,391, wiki 15,603. Claude-Session: https://claude.ai/code/session_01UdJYgjwyfPStkhb1cc6SnZ --- CMakeLists.txt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index acc6b11..0fd7e94 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,13 +29,19 @@ list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}/cmake) include(get_cpm) # --- simdjson -------------------------------------------------------------- -# The parallel stream driver is ours (src/parallel_stream.h), so a released -# simdjson suffices. Pinned by SHA to keep the paper reproducible. +# The parallel stream driver is ours (src/parallel_stream.h), so this is a plain +# master commit rather than a patched branch. It is not a release: simdjson cuts +# releases from a 4.6.x branch, and neither stream_format::newline_delimited nor +# simdjson::slice_at (both merged to master in #2803) has shipped in one yet. A +# release still builds -- the probe below detects their absence and the driver +# falls back to its own slicing -- but it measures a different thing, so the +# reported numbers come from master. Pinned by SHA to keep the paper +# reproducible. CPMAddPackage( NAME simdjson GITHUB_REPOSITORY simdjson/simdjson - GIT_TAG 9b89b82d # PR #2803, as merged to master + GIT_TAG 3839ac681a4a6b4fd09b4d7e03229b35bcd909d7 # master, 2026-08-26 OPTIONS "SIMDJSON_DEVELOPER_MODE OFF" "BUILD_SHARED_LIBS OFF") # --- lemire/counters: hardware performance counters (header only) ---------- From 018f29a9c8d589b33ee567cb4c68559351b2b619 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 29 Aug 2026 16:55:10 -0400 Subject: [PATCH 08/11] Tighten the options the parallel driver added Three loose ends from the driver's own commits. run_parallel defaulted static_partition to false in the header while parallel::options and --assign both default it to true. Nothing hit the disagreement, because main.cpp always passes the flag explicitly, but a caller who omitted it would have measured the other policy. --assign took any value that was not "static" as dynamic, so --assign statc silently changed what was measured. It is now rejected. --engine-only gates the scaling section only; the other sections still run every engine. The help text said "run only this engine", which overstates it. Say what it does and point at --sections scaling, which is what makes the isolation complete. Claude-Session: https://claude.ai/code/session_01UdJYgjwyfPStkhb1cc6SnZ --- src/main.cpp | 31 +++++++++++++++++++++---------- src/simdjson_engine.h | 4 ++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 0e4682f..4adfcfb 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,5 @@ -// jsonbench -- simdjson (incl. PR #2788 parse_many_parallel) vs Pison on a -// stream of JSON documents. +// jsonbench -- simdjson (driven in parallel by src/parallel_stream.h) vs Pison +// on a stream of JSON documents. // // jsonbench --dataset twitter_small_records.json [options] // @@ -46,7 +46,7 @@ struct options { std::string query_name; std::vector threads; int reps = 3; - size_t slice_kb = 64; // parse_many_parallel slice size + size_t slice_kb = 64; // parallel::parse_many slice size size_t batch_mb = 16; // iterate_many batch for the built-in 2-thread mode bool static_partition = true; // --assign dynamic to override bool single_record = false; @@ -57,9 +57,11 @@ struct options { // scripts narrow this so a slice-size or thread-count study does not re-run // the loader and agreement work every time. std::string sections = "load,verify,single,scaling,e2e"; - // Restrict the run to one engine. Aggregate profilers (perf stat -a) cannot - // attribute counters to an engine when several run in the same process, so - // isolating one is the only way to ask "what is *this* engine waiting on". + // Restrict the scaling section to one engine. Aggregate profilers + // (perf stat -a) cannot attribute counters to an engine when several run in + // the same process, so isolating one is the only way to ask "what is *this* + // engine waiting on". The other sections are unaffected: pair this with + // --sections scaling so nothing else runs either. std::string only_engine; // Force a simdjson kernel (haswell, icelake, ...) instead of runtime dispatch. std::string impl; @@ -81,7 +83,7 @@ void usage() { " (default: inferred from the filename)\n" " --threads a,b,c thread counts to sweep (default: 1..hw, doubling)\n" " --reps repetitions per configuration, best wins (default 3)\n" - " --slice-kb parse_many_parallel slice size (default 64)\n" + " --slice-kb parallel slice size (default 64)\n" " --assign slice assignment: static (default) or dynamic\n" " --batch-mb iterate_many batch size (default 16)\n" " --single-record treat the input as one bulky JSON document\n" @@ -90,8 +92,9 @@ void usage() { " engine side by side, then exit\n" " --sections comma list of load,verify,single,scaling,e2e\n" " (default: all)\n" - " --engine-only run only this engine (e.g. simdjson-parallel,\n" - " yyjson-parallel); for profiling one engine alone\n" + " --engine-only restrict the scaling section to this engine (e.g.\n" + " simdjson-parallel); pair with --sections scaling\n" + " to profile one engine alone\n" " --impl force a simdjson kernel (haswell, icelake, ...)\n"); } @@ -107,7 +110,15 @@ bool parse_args(int argc, char **argv, options &o) { else if (a == "--reps") { o.reps = atoi(next().c_str()); } else if (a == "--slice-kb") { o.slice_kb = strtoull(next().c_str(), nullptr, 10); } else if (a == "--batch-mb") { o.batch_mb = strtoull(next().c_str(), nullptr, 10); } - else if (a == "--assign") { o.static_partition = (next() == "static"); } + else if (a == "--assign") { + const std::string mode = next(); + if (mode != "static" && mode != "dynamic") { + std::fprintf(stderr, "--assign takes static or dynamic, not '%s'\n", + mode.c_str()); + return false; + } + o.static_partition = (mode == "static"); + } else if (a == "--single-record") { o.single_record = true; } else if (a == "--verify") { o.verify_only = true; } else if (a == "--dump") { o.dump = strtoull(next().c_str(), nullptr, 10); } diff --git a/src/simdjson_engine.h b/src/simdjson_engine.h index d4f525b..092ca7f 100644 --- a/src/simdjson_engine.h +++ b/src/simdjson_engine.h @@ -31,10 +31,10 @@ extraction run_serial(const char *data, size_t size, query_id q, workload w, std::vector *trace = nullptr, size_t trace_limit = 0); -// experimental::parse_many_parallel from simdjson PR #2788. +// This repository's own parallel stream driver, src/parallel_stream.h. extraction run_parallel(const char *data, size_t size, query_id q, workload w, size_t threads, size_t slice_bytes, - bool static_partition = false); + bool static_partition = true); const char *implementation_name(); From 3ffecf3b059a4b739216bcebb7737fbe79177ed5 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 29 Aug 2026 16:55:10 -0400 Subject: [PATCH 09/11] Document the parallel driver The README still described the parallel path as simdjson's experimental parse_many_parallel from PR #2788, which is no longer what the benchmark runs, and still gave --slice-kb's default as 1024. Neither --assign, --engine-only nor --impl was documented at all. Add a section on the driver: the slicing rule, why it lives here rather than in simdjson, the delimiter it requires and why comma-delimited input cannot be sliced, the configure-time detection of newline_delimited and slice_at, and the two knobs with the measurements that set their defaults. State plainly that the defaults changed, so numbers collected before this branch are not comparable to numbers collected after it. Claude-Session: https://claude.ai/code/session_01UdJYgjwyfPStkhb1cc6SnZ --- README.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4c8bc5d..4337d0b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # jsonstreambench A fair benchmark for parsing **streams of JSON documents**, comparing -[simdjson](https://github.com/simdjson/simdjson) — including the experimental -parallel stream parser of -[PR #2788](https://github.com/simdjson/simdjson/pull/2788) — against +[simdjson](https://github.com/simdjson/simdjson) — driven across many threads by +the slicing rule in `src/parallel_stream.h` — against [Pison](https://github.com/AutomataLab/Pison), on Pison's own corpus and queries. @@ -27,14 +26,27 @@ jsonbench --dataset [options] (default: inferred from the filename) --threads a,b,c thread counts to sweep (default: 1..hw, doubling) --reps repetitions per configuration, best wins (default 3) - --slice-kb parse_many_parallel slice size (default 1024) + --slice-kb parallel slice size (default 64) + --assign slice assignment: static (default) or dynamic + --batch-mb iterate_many batch size (default 16) --sections 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 print the first n extracted values from each engine + --engine-only run only this engine in the scaling section + --impl force a simdjson kernel (haswell, icelake, ...) + --levels override Pison's level_num ``` -Output is one `RESULT key=value ...` line per measured configuration. +Output is one `RESULT key=value ...` line per measured configuration. Each +carries `spread_pct`, the gap between the slowest and fastest repetition +relative to the fastest: only the best repetition is reported, and how +repeatable it was varies enormously between machines. + +`--engine-only` narrows the scaling section to one engine, which is what makes +an aggregate profile interpretable — `perf stat -a` cannot attribute a counter +to an engine when several run in the same process. Pair it with +`--sections scaling` so nothing else runs either. ## How the comparison is kept fair @@ -79,6 +91,91 @@ 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. +## The parallel driver + +`src/parallel_stream.h` is this repository's own driver, not a simdjson API. +An earlier version of it lived in simdjson as an experimental header +([PR #2788](https://github.com/simdjson/simdjson/pull/2788)); it belongs in user +code instead. Nothing in it needs to be inside the library — it is a slicing +rule plus a thread pool over the public `iterate_many` interface — and keeping +it out means a caller can adapt the decomposition to their own pipeline rather +than accept ours. simdjson is therefore pinned to an ordinary master commit +rather than to a patched branch, and the benchmark measures a decomposition +anyone can write. + +The rule: cut the input into fixed-size slices, snap both ends forward to the +next delimiter so slices abut and no document is split, and give each worker its +own parser and its own output vector. Nothing is shared on the hot path and no +lock is taken. Results come back as one vector per worker, so values keep their +order within a worker but not across the input. + +This needs a delimiter that cannot occur inside a JSON value: a line feed for +NDJSON, or a record separator (0x1E) for RFC 7464. Comma-delimited input cannot +be sliced this way at all, because a top-level comma is only found by a serial +structural scan. + +The corpus is strictly one document per line — the same structure Pison is +handed as its record table — so simdjson is told that too, with +`stream_format::newline_delimited`, which lets it skip the tail of a partially +read document instead of walking its structural characters. + +That format and `simdjson::slice_at` are detected at configure time rather than +required, so the tree still builds against a simdjson that has neither; it then +falls back to slicing with `memchr`. That is not a corner case: simdjson cuts +releases from a `4.6.x` branch, and neither feature has shipped in one, so +*every* release takes the fallback. The pin is a master commit for exactly this +reason, and the numbers below are master's. + +`src/dom_parallel.h` applies the same rule to yyjson, RapidJSON, Boost.JSON and +nlohmann. The slicing does not know what parses a document, and running the +conventional parsers under it is how that claim is checked rather than asserted. + +Two knobs control it, and they interact: + +* `--slice-kb` (default 64). Smaller slices balance better and hold less live + data per worker. +* `--assign` (default `static`). Under `static`, each worker owns one contiguous + run of slices and walks it forward. Under `dynamic`, workers claim the next + free slice from a shared atomic counter, which scatters a worker's regions + across the input — at 128 threads and 64 KB slices, consecutive regions sit + 8 MB apart. + +The defaults changed when this driver landed: the slice size was 1024 KB and the +assignment was dynamic. **Numbers collected before that change are not +comparable to numbers collected after it.** What follows is the measurement that +motivated it, on 2× Xeon Gold 6548N (64 physical cores, 128 threads), best of +three runs of ten repetitions, GB/s on the `query` phase. + +Slice size is the larger effect, and it is not a plateau: at 128 threads a +1024 KB slice is a third slower than a 64 KB one, because 128 workers × 1 MB of +live data no longer fits where it needs to. Assignment then decides how far down +the slice size can be pushed before a shared counter becomes the bottleneck. + +| nspl, 128 threads | 8 KB | 16 KB | 32 KB | 64 KB | 256 KB | 1024 KB | +|---|---|---|---|---|---|---| +| `--assign static` | 58.3 | 57.9 | 61.8 | 55.6 | 57.8 | 36.4 | +| `--assign dynamic` | 27.2 | 43.1 | 46.9 | 62.8 | 56.2 | 35.9 | + +Static assignment is what makes the choice of slice size stop mattering: it holds +55–62 GB/s across two orders of magnitude, while dynamic loses more than half its +throughput once a slice is small enough that workers contend on the counter. + +At the 64 KB default the two policies are close on most of the corpus, and the +gap opens with thread count. At 128 threads: + +| | TT | BB | GMD | NSPL | WM | WP | +|---|---|---|---|---|---|---| +| `static` | 84.2 | 86.4 | 73.6 | 56.1 | 92.6 | 84.4 | +| `dynamic` | 77.6 | 80.6 | 46.8 | 61.9 | 85.2 | 65.3 | + +Google Maps is the case that decides it, at 1.57×; Wikipedia follows at 1.29×. +Both have highly variable document sizes, which is where a worker's regions +being scattered across the input costs the most. NSPL is the one dataset that prefers +dynamic, by 9%: its documents are small and uniform, so there is nothing for +locality to buy and the counter is never hot. Static is the default because its +worst case is that 9% while its best case is 1.57×, and because it removes the +small-slice cliff entirely. + ## Corpus `./datasets.sh` obtains the whole corpus: From 5dfb1d9f121d0b90e528e48f9752abf811d2aca4 Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 29 Aug 2026 17:16:49 -0400 Subject: [PATCH 10/11] Condense the parallel-driver section Nine paragraphs and two tables to describe one slicing rule and two knobs was more than the section earns. Three paragraphs: what the driver is and why it lives here, the rule and what it requires of the input, and the knobs with the evidence for their defaults. The figures are also re-measured. The old ones came from the previous pin and from a machine with a core busy, which had moved nspl far enough to invert its sign: it appeared to prefer dynamic by 9%, and on an idle machine against master it prefers static by 13%. Walmart is now the only dataset that prefers dynamic, by 2%, which is inside the spread. Claude-Session: https://claude.ai/code/session_01UdJYgjwyfPStkhb1cc6SnZ --- README.md | 126 ++++++++++++++++-------------------------------------- 1 file changed, 38 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index 0131986..784fafe 100644 --- a/README.md +++ b/README.md @@ -98,98 +98,48 @@ per gigabyte from `getrusage`, which does aggregate all threads. ## The parallel driver -`src/parallel_stream.h` is this repository's own driver, not a simdjson API. -An earlier version of it lived in simdjson as an experimental header +`src/parallel_stream.h` is this repository's own driver, not a simdjson API. An +earlier version lived in simdjson as an experimental header ([PR #2788](https://github.com/simdjson/simdjson/pull/2788)); it belongs in user -code instead. Nothing in it needs to be inside the library — it is a slicing -rule plus a thread pool over the public `iterate_many` interface — and keeping -it out means a caller can adapt the decomposition to their own pipeline rather -than accept ours. simdjson is therefore pinned to an ordinary master commit -rather than to a patched branch, and the benchmark measures a decomposition -anyone can write. +code instead, because nothing in it needs to be inside the library — it is a +slicing rule plus a thread pool over the public `iterate_many` interface — and +keeping it out means a caller can adapt the decomposition to their own pipeline +rather than accept ours. simdjson is therefore pinned to an ordinary master +commit rather than a patched branch. `src/dom_parallel.h` applies the same rule +to yyjson, RapidJSON, Boost.JSON and nlohmann: the slicing does not know what +parses a document, and running the conventional parsers under it is how that +claim is checked rather than asserted. The rule: cut the input into fixed-size slices, snap both ends forward to the next delimiter so slices abut and no document is split, and give each worker its -own parser and its own output vector. Nothing is shared on the hot path and no -lock is taken. Results come back as one vector per worker, so values keep their -order within a worker but not across the input. - -This needs a delimiter that cannot occur inside a JSON value: a line feed for -NDJSON, or a record separator (0x1E) for RFC 7464. Comma-delimited input cannot -be sliced this way at all, because a top-level comma is only found by a serial -structural scan. - -The corpus is strictly one document per line — the same structure Pison is -handed as its record table — so simdjson is told that too, with -`stream_format::newline_delimited`, which lets it skip the tail of a partially -read document instead of walking its structural characters. - -That format and `simdjson::slice_at` are detected at configure time rather than -required, so the tree still builds against a simdjson that has neither; it then -falls back to slicing with `memchr`. That is not a corner case: simdjson cuts -releases from a `4.6.x` branch, and neither feature has shipped in one, so -*every* release takes the fallback. The pin is a master commit for exactly this -reason, and the numbers below are master's. - -`src/dom_parallel.h` applies the same rule to yyjson, RapidJSON, Boost.JSON and -nlohmann. The slicing does not know what parses a document, and running the -conventional parsers under it is how that claim is checked rather than asserted. - -Two knobs control it, and they interact: - -* `--slice-kb` (default 64). Smaller slices balance better and hold less live - data per worker. -* `--assign` (default `static`). Under `static`, each worker owns one contiguous - run of slices and walks it forward. Under `dynamic`, workers claim the next - free slice from a shared atomic counter, which scatters a worker's regions - across the input — at 128 threads and 64 KB slices, consecutive regions sit - 8 MB apart. - -The defaults changed when this driver landed: the slice size was 1024 KB and the -assignment was dynamic. **Numbers collected before that change are not -comparable to numbers collected after it.** What follows is the measurement that -motivated it, on 2× Xeon Gold 6548N (64 physical cores, 128 threads), best of -three runs of ten repetitions, GB/s on the `query` phase. - -Slice size is the larger effect, and it is not a plateau: at 128 threads a -1024 KB slice is a third slower than a 64 KB one, because 128 workers × 1 MB of -live data no longer fits where it needs to. Assignment then decides how far down -the slice size can be pushed before a shared counter becomes the bottleneck. - -| nspl, 128 threads | 8 KB | 16 KB | 32 KB | 64 KB | 256 KB | 1024 KB | -|---|---|---|---|---|---|---| -| `--assign static` | 58.3 | 57.9 | 61.8 | 55.6 | 57.8 | 36.4 | -| `--assign dynamic` | 27.2 | 43.1 | 46.9 | 62.8 | 56.2 | 35.9 | - -Static assignment is what makes the choice of slice size stop mattering: it holds -55–62 GB/s across two orders of magnitude, while dynamic loses more than half its -throughput once a slice is small enough that workers contend on the counter. - -At the 64 KB default the two policies are close on most of the corpus, and the -gap opens with thread count. At 128 threads: - -| | TT | BB | GMD | NSPL | WM | WP | -|---|---|---|---|---|---|---| -| `static` | 84.2 | 86.4 | 73.6 | 56.1 | 92.6 | 84.4 | -| `dynamic` | 77.6 | 80.6 | 46.8 | 61.9 | 85.2 | 65.3 | - -Google Maps is the case that decides it, at 1.57×; Wikipedia follows at 1.29×. -Both have highly variable document sizes, which is where a worker's regions -being scattered across the input costs the most. NSPL is the one dataset that prefers -dynamic, by 9%: its documents are small and uniform, so there is nothing for -locality to buy and the counter is never hot. Static is the default because its -worst case is that 9% while its best case is 1.57×, and because it removes the -small-slice cliff entirely. - -Both defaults were tuned on the six Pison datasets, whose documents are small. -They do not transfer to a corpus of bulky records: when a document is much -larger than a slice, every slice that starts inside it scans forward to the -document's end before finding it has nothing to do, so the driver rescans the -body once per overlapping slice. On synthetic input with 1.36 MB documents -- -the size of the largest OpenAlex author record -- 64 KB slices run at 2.4 GB/s -against 6.0 GB/s for 1024 KB, and 16 KB slices at 0.8 GB/s. Results stay -correct at every size; only throughput suffers. Raise `--slice-kb` above the -longest document for such a corpus. +own parser and its own output vector. Nothing is shared on the hot path, and +values keep their order within a worker but not across the input. This needs a +delimiter that cannot occur inside a JSON value — a line feed for NDJSON, a +record separator (0x1E) for RFC 7464 — so comma-delimited input cannot be sliced +this way at all, its top-level commas being findable only by a serial structural +scan. Our corpus is strictly one document per line, so simdjson is told that too +with `stream_format::newline_delimited`, which lets it skip the tail of a +partially read document rather than walk it. That format and +`simdjson::slice_at` are detected at configure time rather than required: every +simdjson *release* lacks both, since releases are cut from a `4.6.x` branch, and +against one the driver falls back to slicing with `memchr`. + +Two knobs control it, and the right values depend on the corpus rather than on +the machine. `--assign` (default `static`) gives each worker one contiguous run +of slices; `dynamic` instead has workers claim the next free slice from a shared +counter, which scatters their regions across the input. `--slice-kb` (default +64) sets the slice. Both changed when this driver landed — they were 1024 KB and +`dynamic` — so earlier numbers are not comparable. On nspl at 128 threads static +holds 60–63 GB/s from 8 KB to 64 KB while dynamic collapses to 26 GB/s at 8 KB; +across the six Pison datasets at the 64 KB default, static wins on five — Google +Maps by 1.55×, Wikipedia by 1.32× — losing 2% only on Walmart, within +run-to-run spread. Both defaults +were tuned on those datasets, whose documents are small, and **they do not +transfer to a corpus of bulky records**: a document larger than a slice is +rescanned once per overlapping slice, so on synthetic input of 1.36 MB documents +— the largest OpenAlex author record — 64 KB slices run at 2.4 GB/s against +6.0 GB/s for 1024 KB. Results stay correct at every size; only throughput +suffers. Raise `--slice-kb` above the longest document for such a corpus. ## Corpus From 9c31cc666f0be909949d2a69627e1ce9569d7a7c Mon Sep 17 00:00:00 2001 From: Daniel Lemire Date: Sat, 29 Aug 2026 17:17:27 -0400 Subject: [PATCH 11/11] Keep measured figures out of the README The parallel-driver section quoted throughput from one machine, which dates the README against every rerun and invites the reader to treat one box's numbers as the result. Say what the knobs do and which way each effect runs; leave the figures to the runs that produce them. Claude-Session: https://claude.ai/code/session_01UdJYgjwyfPStkhb1cc6SnZ --- README.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 784fafe..3f0e840 100644 --- a/README.md +++ b/README.md @@ -127,18 +127,15 @@ against one the driver falls back to slicing with `memchr`. Two knobs control it, and the right values depend on the corpus rather than on the machine. `--assign` (default `static`) gives each worker one contiguous run of slices; `dynamic` instead has workers claim the next free slice from a shared -counter, which scatters their regions across the input. `--slice-kb` (default -64) sets the slice. Both changed when this driver landed — they were 1024 KB and -`dynamic` — so earlier numbers are not comparable. On nspl at 128 threads static -holds 60–63 GB/s from 8 KB to 64 KB while dynamic collapses to 26 GB/s at 8 KB; -across the six Pison datasets at the 64 KB default, static wins on five — Google -Maps by 1.55×, Wikipedia by 1.32× — losing 2% only on Walmart, within -run-to-run spread. Both defaults -were tuned on those datasets, whose documents are small, and **they do not -transfer to a corpus of bulky records**: a document larger than a slice is -rescanned once per overlapping slice, so on synthetic input of 1.36 MB documents -— the largest OpenAlex author record — 64 KB slices run at 2.4 GB/s against -6.0 GB/s for 1024 KB. Results stay correct at every size; only throughput +counter, which scatters their regions across the input and, once slices are +small enough for the counter to be contended, costs a large fraction of the +throughput. `--slice-kb` (default 64) sets the slice. Both changed when this +driver landed — they were 1024 KB and `dynamic` — so numbers collected before +that are not comparable to numbers collected after it. Both were also tuned on +the six Pison datasets, whose documents are small, and **they do not transfer to +a corpus of bulky records**: a document larger than a slice is rescanned once +per overlapping slice, which costs throughput in proportion to the square of the +document over the slice. Results stay correct at every size; only throughput suffers. Raise `--slice-kb` above the longest document for such a corpus. ## Corpus