Skip to content

Speed up json_view on x86-64 Linux and make the comparison fair - #5765

Closed
nlohmann wants to merge 5 commits into
json-view/24-view-token-digitsfrom
json-view/25-linux-perf
Closed

nlohmann wants to merge 5 commits into
json-view/24-view-token-digitsfrom
json-view/25-linux-perf

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Part of the stack for the zero-copy view (#5295). Speeds up json_view on x86-64 Linux, closes most of the gap between GCC and Clang, formats the digits of numbers faster for json::dump() and json_view on all platforms, and fixes the comparison benchmark of #5627, which made json_view look slower on Linux than it is.

Summary

  • SSSE3 UTF-8 check on every x86-64 CPU that has it. Until now, the vector UTF-8 check of the view's parser needed SSSE3 at compile time (JSON_VIEW_USE_SSSE3 with -mssse3), so a default x86-64 build validated non-ASCII text one sequence at a time.
    • The check is now compiled for SSSE3 with a function attribute (GCC 4.9 and later, Clang; MSVC compiles the intrinsics anyway). It is used where CPUID reports SSSE3, which all x86-64 CPUs since about 2011 do.
    • The answer is cached in a std::atomic<int> that is initialized at compile time: no guard of a local static, no global constructor (-Wglobal-constructors).
    • The definitions do not depend on compiler flags, so different translation units cannot violate the ODR.
    • JSON_VIEW_USE_SSSE3 now only skips the CPU check; JSON_VIEW_NO_SIMD still selects the portable code.
  • Always inline detail::read_eight_bytes() and detail::parse_eight_digits(). GCC called both as functions in the number loops of the lexer and of the view's parser (52 call sites). This cost about 10% on citm_catalog at -O2.
  • Strings vector-first on x86-64: with SSE2, string runs are checked 16 bytes at a time from their first byte instead of one byte at a time for the first 8-16 bytes. One compare finds the end of most keys and short values; on x86-64 that is faster than a branch per byte. AArch64 keeps the byte steps: there a NEON mask costs more and the branches predict well, and vector-first was 20% slower on Apple M1.
  • No stall when entering an object or array: open() stores the parent's frame field by field. Built on the stack and copied, it was read back by loads wider than its stores, which waited until the stores were done (store forwarding fails); this was 18% of the parse time of citm_catalog on x86-64. Apple M1 forwards such stores and does not change.
  • Faster digit formatting (json::dump() and json_view's dump()):
    • zmij::to_shortest() keeps the last digit apart from the 15 or 16 digits before it, as upstream Zmij does, so they are converted without first multiplying by 10 and counting digits.
    • dtoa_impl::write_shortest() converts the 16 digits at once in one vector register: with SSE2 on x86-64 and NEON on 64-bit Arm, both baseline, so there is no CPU check. Other CPUs convert eight digits at a time.
    • The decimal point is inserted inside the register. Before, the digits were stored and read back right away to move them by one byte, and that load waited for the stores (store forwarding fails); this was about 25% of the time to write a double on x86-64.
    • json::dump() writes floats and integers straight into its write buffer instead of copying them from number_buffer (the same stall), and integers below 10^16 eight digits at a time.
    • json_view's dump() writes doubles from their bits, without the checks of to_chars(), and tokens of up to 15 digits through the same code (their digit count is known from the token). Its own NEON writer is removed.
    • Subnormals still use write_decimal().
  • Dump output grows in steps: json_view's dump() reserves its size estimate (the source extent) and grows the output string in steps of 64 KiB within it, instead of resizing it to the estimate at once. resize() zero-fills, and for a pretty-printed source the estimate is far larger than the compact output: citm_catalog dump 342 -> 283 µs on x86-64, 144 -> 134 µs on Apple M1.
  • Fairer comparison benchmark (tests/benchmarks/json_view, see Compare json_view with yyjson, simdjson, and Boost.JSON #5627):
    • Each engine runs once untimed before each timed call. Before, json::parse ran last in each round and freed about 40,000 nodes; the next engine, always json_view, paid for glibc cleaning them up. This made json_view look 1.7 times slower than yyjson on citm_catalog traverse.
    • bench_corpus compares the dump with source numbers (number_format::source) with yyjson writing numbers read as raw text (YYJSON_READ_NUMBER_AS_RAW); both write the same bytes.
    • New column "simdjson DOM (fresh)"; "json_view (reused)" is also timed for traverse and select. simdjson DOM reuses its parser, so it is to be compared with the reused document.
    • compare.py makes --data, --corpus, and --build-dir absolute. Before, the benchmarks, which run in the build directory, aborted with a relative --data.
    • compare.py downloads into a .part file and removes an archive whose SHA-256 does not match, so an interrupted download is no longer kept forever.
    • The benchmarks report files they cannot open instead of aborting.
  • Docs: JSON_VIEW_USE_SSSE3 describes the run-time check. read() notes that reusing a document avoids the page faults of a fresh node index, about 40% of the parse time of a 55 MB document on x86-64 Linux.

Results

x86-64 Linux (KVM, Haswell with AVX2, Ubuntu 24.04), compare.py --download --rounds 30, fresh documents. Times in µs; the yyjson and simdjson DOM columns are from the same run as "after" (Clang / GCC).

GCC 13 before GCC 13 after Clang 18 before Clang 18 after yyjson simdjson DOM
twitter parse 706 400 631 367 435 / 433 337 / 331
citm_catalog parse 1218 948 985 939 995 / 1098 816 / 770
citm_catalog traverse 2116 1138 1926 1144 1178 / 1228 1075 / 1066

With twitter and citm_catalog, json_view is now ahead of yyjson with both compilers, and 10–20% behind simdjson, which uses AVX2.

Parse after the two parser changes (x86-64, pinned to one core, best of 7, µs):

GCC 13 before GCC 13 after Clang 18 before Clang 18 after simdjson DOM (AVX2)
twitter 352 306 316 288 338
citm_catalog 1000 770 843 692 750
canada 1941 1687 1978 1722 ~3270

simdjson's remaining lead on twitter and citm_catalog came from AVX2: with its SSE4.2 kernel forced (SIMDJSON_FORCE_IMPLEMENTATION=westmere), simdjson is 8-18% slower than json_view. AVX2 does not help json_view: 32-byte versions of the string and indentation loops were slower than the 16-byte ones, as most runs are shorter than 32 bytes. On Apple M1, json_view is ahead of yyjson and simdjson for parse, traverse, select, and dump of twitter, citm_catalog, and jeopardy.

Digit formatting (best of several runs; the VPS had other load, so a few percent are noise):

before after
to_chars(), canada doubles, x86-64 (GCC / Clang) 35 ns 29 ns
to_chars(), canada doubles, Apple M1 18.8 ns 14.2 ns
json::dump() canada, M1 3.43 ms 2.44 ms
json::dump() canada, x86-64 (compare.py, GCC 13) 5.84 ms 4.89 ms
json::dump() citm_catalog, x86-64 1.24 ms 1.01 ms
json_view dump canada, x86-64 7.84 ms 7.22 ms (yyjson 3.85, simdjson DOM 12.9)
json_view dump canada, M1 4.49 ms 3.76 ms

to_chars() alone is now faster than yyjson's writer of one number (34 ns on x86-64). The json_view dump of twitter, citm_catalog, and jeopardy is level with or ahead of yyjson and 2.4-2.9x faster than simdjson DOM.

Dump with the numbers of the input (µs; same output bytes):

json_view (source numbers) yyjson (raw numbers)
canada, x86-64 / M1 903 / 407 793 / 804
citm_catalog, x86-64 / M1 283 / 134 307 / 181
twitter, x86-64 / M1 169 / 73 201 / 107
jeopardy, x86-64 / M1 (ms) 72.2 / 15.0 73.6 / 20.5

On canada on x86-64, yyjson's lead is mostly the zero-fill of std::string::resize(), which yyjson's malloced output does not have. Before C++23 a string cannot grow without it, and C++23's resize_and_overwrite requires the whole dump inside its callback within a fixed size.

What tuning cannot close:

  • canada traverse: about 10% behind yyjson. The lazy float conversion runs at fast_float's speed; yyjson converts during the parse.
  • canada dump: about 1.9x behind yyjson. The formatting is now faster than yyjson's; the rest is converting each 17-digit token to a double (about 20 ns), which the lazy design does during the dump.
  • Fresh small documents: a fixed overhead of about 25 ns each.

Tried and dropped:

  • A register-only write_decimal(): slower. The store-forwarding stall it avoids is only about 14% of that function.
  • A larger inline node array: no measurable change.
  • Transparent huge pages: help 55 MB documents but slow down mid-sized ones.
  • SSSE3 shuffles for the digit layout: 0.4 ns faster than SSE2, not worth a CPU check.
  • Moving the digits with 128-bit shifts in general-purpose registers: slower than the reload.
  • Counting the digits of short json_view tokens: 17% slower on M1 than taking the count from the token.
  • AVX2 (32 bytes per step) for strings and indentation: slower than SSE2. Compiling everything for AVX2 is as fast as -msse4.1 (GCC's -O3 code is worse without SSE4.1; not pursued).
  • Indentation with SSE2 (16 bytes per step): faster with GCC on citm_catalog, slower with Clang on twitter.
  • resize_and_overwrite (C++23) for the dump output: 9-20% faster, but growing the string and writing later violates its precondition (all bytes up to the returned size written by the callback).

Verification

  • json_view unit tests (test-json_view*, GCC 13 and Clang 18, x86-64): assertion results identical to the base branch. The base already fails 5 assertions on Linux, 4 in the comment handling of test-json_view_builder and 1 in test-json_view, as part of the current CI breakage.
  • Fuzzing: 846,567 libFuzzer runs of fuzzer-parse_json_view with ASan and UBSan, with the run-time dispatch active.
  • Old compilers (Docker, linux/amd64): GCC 4.9, 5, 7, 9, 12 and Clang 5, 10 build it, detect SSSE3, and agree with json::accept on 200,000 random UTF-8 strings each, including ill-formed ones.
  • Warnings: the CI's Clang warning flags (-Weverything -Werror) are clean for x86_64 and arm64, C++11/17/20. With the CI's own compilers (gcc:16, silkeh/clang:dev in Docker) and flags, the digit formatting adds no warning on amd64 or arm64 (GCC's -Winline at -O2 names write_shortest() instead of to_decimal()); clang-tidy with the repository's configuration is clean on unit-to_chars.cpp.
  • Digit formatting: for 100M doubles (normal, subnormal, powers of two, short decimals), to_chars() writes the same text as before, with SSE2 (x86-64), NEON (arm64), and GCC 4.9/5, Clang 3.5/5 in Docker; also into short buffers. Integer dumps equal std::to_string() for 10M+ values and all boundaries. json_view's dump equals ordered_json::parse(text).dump() for 6.4M random number tokens. The full test suite with ASan and UBSan (Apple M1 with Clang, x86-64 with GCC 13) fails only the 5 assertions that already fail on the base branch; also after the parser changes, whose warnings with the CI's compilers equal the base branch's.

Public API

No breaking changes:

  • JSON_VIEW_USE_SSSE3 keeps its meaning for the result. Without it, x86-64 builds now use SSSE3 when the CPU has it; with it, the CPU is not checked. The same inputs are accepted or rejected either way.
  • read_eight_bytes() and parse_eight_digits() are in detail and only change their inlining.
  • The digit formatting changes only code in detail and writes the same text. json::dump() no longer uses number_buffer for floats and integers (a private member).

Written by Claude Code.

🤖 Generated with Claude Code

The vector UTF-8 check of json_view needed SSSE3 at compile time
(JSON_VIEW_USE_SSSE3 with -mssse3), so default x86-64 builds validated
non-ASCII text one sequence at a time. The check is now compiled for
SSSE3 with a function attribute (GCC 4.9 and later, Clang; MSVC compiles
the intrinsics anyway) and used where CPUID reports SSSE3. The answer is
kept in an atomic that is initialized at compile time, so neither a
guard nor a global constructor is needed. The definitions do not depend
on compiler flags, so there is no ODR issue. JSON_VIEW_USE_SSSE3 now only
skips the CPU check.

On x86-64 Linux (Haswell), twitter.json parses 23% faster with Clang 18
and 25-34% faster with GCC 13, now ahead of yyjson.

Also always inline read_eight_bytes() and parse_eight_digits(): GCC
called both in the number loops of the lexer and of json_view (52 call
sites), which cost about 10% on citm_catalog.json at -O2.

Document that reusing a document with read() avoids the page faults of
a fresh node index (about 40% of a 55 MB parse on x86-64 Linux).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- compare.py: make --data, --corpus, and --build-dir absolute, since the
  benchmarks run in the build directory; download into a .part file and
  remove an archive whose SHA-256 does not match, so that an interrupted
  download is not kept
- bench_view/bench_corpus/bench_edit: report files that cannot be opened instead of
  aborting; run each engine once untimed before its timed call, so that
  no engine pays for the allocator cleaning up after the previous one
  (with glibc, json_view after json::parse looked 1.7x slower on
  citm_catalog traverse); add "simdjson DOM (fresh)" and time
  "json_view (reused)" for traverse and select too
- README: explain fresh vs. reused documents and page faults on Linux

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann added this pull request to stack #5739 October 5, 2026 18:26
@github-actions github-actions Bot added documentation L tests python Pull requests that update Python code labels Oct 5, 2026
Comment thread tests/benchmarks/json_view/bench_corpus.cpp Dismissed
Comment thread tests/benchmarks/json_view/bench_edit.cpp Dismissed
Comment thread tests/benchmarks/json_view/bench_view.cpp Dismissed
- zmij::to_shortest() keeps the last digit apart from the 15 or 16 digits
  before it, and dtoa_impl::write_shortest() converts those at once: with
  SSE2 on x86-64 and NEON on 64-bit Arm (no CPU check needed), else eight
  digits at a time. The decimal point is inserted in the register; reading
  the digits back right after storing them stalled store forwarding.
- json::dump() writes floats and integers straight into its write buffer
  instead of copying them from number_buffer, and integers below 10^16
  eight digits at a time.
- json_view's dump() writes doubles from their bits and tokens of up to 15
  digits through the same code; its own NEON writer is removed.

to_chars() on canada.json: 35 -> 29 ns per double (x86-64), 18.8 -> 14.2 ns
(Apple M1); json::dump() of canada.json 16-29% faster, of citm_catalog.json
14-19%.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- With SSE2, string runs are checked 16 bytes at a time from their first
  byte: one compare finds the end of most keys and short values, faster on
  x86-64 than a branch per byte. AArch64 keeps the byte steps (there, a
  NEON mask costs more and the branches predict well; vector-first was 20%
  slower on Apple M1).
- open() stores the parent's frame field by field. Built on the stack and
  copied, it was read back by loads wider than its stores, which waited for
  them (store forwarding fails): 18% of the time on citm_catalog.json.

Parse on x86-64 (GCC 13 / Clang 18, us): twitter 352 -> 306 / 316 -> 288,
citm_catalog 1000 -> 770 / 843 -> 692, canada 1941 -> 1687 / 1978 -> 1722.
Unchanged on Apple M1.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- The output string reserves the size estimate (the source extent) and grows
  in steps of 64 KiB within it, instead of being resized to the estimate at
  once: resize() zero-fills, and for a pretty-printed source the estimate is
  far larger than the compact output. citm_catalog dump: 342 -> 283 us on
  x86-64, 144 -> 134 us on Apple M1.
- bench_corpus compares the dump with source numbers with yyjson writing
  numbers read as raw text (YYJSON_READ_NUMBER_AS_RAW); both write the same
  bytes.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann

nlohmann commented Oct 6, 2026

Copy link
Copy Markdown
Owner Author

The zero-copy view stack (#5295) was rebased onto develop and regrouped from 20 PRs into 10, one squashed commit each. The changes of this PR are now in #5634 (core digit formatting and inlining), #5628 (SSSE3 dispatch, string scan, builder stall), #5633 (view dump), and #5627 (benchmark fixes); its review comments stay here for reference.


Written by Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation L python Pull requests that update Python code tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants