From 10aa3a5d82fefc03c82508c2b4fd885a6c79f3ea Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Sat, 7 Nov 2026 15:38:19 +0100 Subject: [PATCH] docs: blog post: describe the performance work of #6020, #6028-#6031 and #6033 Theme 1 gets the iterator pairs of std::string and std::vector, a new subsection on the fused contiguous parser after yyjson's reader, and the FILE buffer window after gnulib's freadptr(). Theme 2 shows the reduced SWAR test and the (c ^ 2) < 33 vector compare. Themes 4 and 6 get the binary strings without a zero fill and the 64 KiB window of the vector sink. Appendices H and J list the new pull requests and their checks. The numbers of the suite, the timeline, the comparison and the compile times are not remeasured yet. Signed-off-by: Niels Lohmann --- .../docs/home/blog/how_3_13_0_got_faster.md | 162 +++++++++++++++--- 1 file changed, 135 insertions(+), 27 deletions(-) diff --git a/docs/mkdocs/docs/home/blog/how_3_13_0_got_faster.md b/docs/mkdocs/docs/home/blog/how_3_13_0_got_faster.md index 7f6417e38..270e1e9d1 100644 --- a/docs/mkdocs/docs/home/blog/how_3_13_0_got_faster.md +++ b/docs/mkdocs/docs/home/blog/how_3_13_0_got_faster.md @@ -32,7 +32,8 @@ This post is about *how*. Hardly any of the techniques are new: we borrowed them [simdjson](https://github.com/simdjson/simdjson), [yyjson](https://github.com/ibireme/yyjson), [fast_float](https://github.com/fastfloat/fast_float), [Żmij](https://github.com/vitaut/zmij), [serde_json](https://github.com/serde-rs/json), [Glaze](https://github.com/stephenberry/glaze), -[SQLite](https://sqlite.org), [Boost.JSON](https://github.com/boostorg/json), or a textbook, and we name the source at +[SQLite](https://sqlite.org), [Boost.JSON](https://github.com/boostorg/json), [gnulib](https://www.gnu.org/software/gnulib/), +or a textbook, and we name the source at each step. What is worth writing about is the *combination*, and the conditions it had to work under. It is a header-only C++11 library whose 3.x API has been stable for almost nine years, and whose value type is a template over its string, number, object, array, and allocator types. Input comes from strings, streams, `FILE*`, iterator pairs, wide @@ -202,14 +203,16 @@ iterators over a `std::list`, or a user's own adapter. But it makes every ```mermaid flowchart LR src[/"input"/] --> q{{"what is it?"}} - q -->|"std::string, std::vector,
pointers, contiguous ranges"| c["contiguous:
scan the whole buffer"] + q -->|"std::string, std::vector,
their iterators, pointers,
contiguous ranges"| c["contiguous:
scan the whole buffer"] q -->|"std::istream"| w["window:
scan the streambuf's get area"] - q -->|"FILE*"| f["one lock per parse,
unlocked reads"] + q -->|"FILE*"| f["window:
scan the FILE's buffer"] q -->|"other iterators,
user adapters"| b["byte at a time"] ```

The four kinds of input the lexer distinguishes in 3.13.0. Everything on the fast paths -falls back to the byte path on anything unusual, so the result and the error messages stay the same.

+falls back to the byte path on anything unusual, so the result and the error messages stay the same. The `FILE*` +window needs glibc or Apple's C library; elsewhere, a `FILE*` is read one character at a time with one lock per +parse.

### Detecting the input in C++11 @@ -236,7 +239,8 @@ which also keeps MSVC from warning about constant conditions. `get()` still look token bookkeeping are now calls. For an adapter that can seek, their `std::true_type` overloads are empty and cost nothing, because the position and the text of a token are derived only when an error message needs them (theme 4). For a `std::string`, what remains is the branch on -`next_unget` and the adapter's bounds check and pointer increment. +`next_unget` and the adapter's bounds check and pointer increment. For the most common calls, not even that remains: +they don't use the lexer at all ([One loop instead of two](#one-loop-instead-of-two)). ### Contiguous input @@ -249,6 +253,43 @@ are scanned in bulk (themes malformed, so error messages and byte positions stay the same, with one deliberate correction (the column after a number at the end of a line). [Appendix J](#j-validation) describes how that was checked. +A pair of iterators is harder. Before C++20, `s.begin()` and `s.end()` of a `std::string` can't be told from the +iterators of any other random-access container, so `json::parse(s.begin(), s.end())` was read one character at a time, +while `json::parse(s)` got all the bulk scans. The library now matches the iterator types of `std::string` and of a +`std::vector` of one-byte integers exactly, and turns the pair into the pointer range it covers. That made +`parse(s.begin(), s.end())` 1.7–2.0× as fast on the M1, and `accept()` 2.7–3.1×. + +### One loop instead of two + +Even on a pointer range, the parser and the lexer still talked through an interface: the parser asks for a token, the +lexer finds it and hands it over, and the parser dispatches on its type. The lexer's state, the current character, a +pending `unget()`, and where the token starts, lives in members, so it goes through memory on every token. After all of +the above, `json::accept()` of `twitter.json` still took 2.8× as long as `json_document::accept()` (theme +8), which reads the same bytes. + +[yyjson](https://github.com/ibireme/yyjson)'s reader shows the alternative: one function that holds the input in a +local pointer and in which the grammar, the whitespace and the tokens are one loop. After a `:`, it tests for the +characters that can start a value right there, instead of returning a token type and dispatching on it. 3.13.0 adds +such a parser for input in one block of memory, `contiguous_parser`. Its stack of open arrays and objects is a 64-bit +word with one bit per level, so the kind of the enclosing container is a shift away and stays in a register; every 64 +levels, the word is pushed to a vector. It doesn't build values itself: it drives the library's own SAX handlers, so +`parse()` builds the same `json` with the same code as before. It is used by `parse()` without a callback, by +`accept()`, and by `sax_parse()` with the user's own handler, each with the default options; everything else keeps the +old parser. + +The hard part was the errors. A second parser would have to produce the same exception, message and position for +every malformed input, a large surface for small differences. So it doesn't report errors at all. It stops at the +token the old parser would reject, before it reports that token to the handler, and the old parser runs again on the +same input to report the error. `parse()` gives it a handler that only reports, and `sax_parse()` one that forwards +only `parse_error()` to the user's handler, so that no event arrives twice. An error at the end of a large input costs +`parse()` about 1.2× as much as before; valid input costs nothing extra. + +On the M1, `accept()` got 1.6–2.0× as fast, `sax_parse()` 1.4–1.5×, and `parse()` 10–16% faster, about the same with +GCC and Clang on x86-64. In `accept()`, which needs no values, the parser also skips the digits of a number without +computing its value, except for the rare float whose exponent may reach the largest `double`: another 16% on +`canada.json`. The cost is about 1,000 lines of header, and a translation unit that parses a string and dumps it +compiles 3% slower at `-O0` and 9% at `-O2`. + ### Streams An `std::istream` already has a buffer: the get area of its `streambuf`, `gptr()` to `egptr()`. The lexer now scans that @@ -284,11 +325,26 @@ measurement before the change; now it takes just 9–16% longer. `_lock_file` and `_fgetc_nolock` with the Microsoft runtime). `flockfile` is recursive, so the binary readers' `fread()` and a SAX handler's own `ftell()` keep working. Whereas parsing `citm_catalog.json` from a `FILE*` got 4.4× as fast on macOS in the pull request's measurement, with glibc it got only 1.1–1.3× as fast, because glibc's per-call locking is -already cheap. Two details: MemorySanitizer builds keep `fgetc`, because glibc's inline `getc_unlocked` reads a buffer -that uninstrumented glibc filled. And a SAX handler that waits for another thread using the same `FILE` now deadlocks, -as the documentation says. Both paths can be switched off, with +already cheap. + +That still left a call per character, while a stream had its get area scanned in bulk. But a `FILE` has a buffer too, +and `getc_unlocked()` is a macro that reads it: glibc's compares `_IO_read_ptr` with `_IO_read_end`, Apple's counts +down `_r` and advances `_p`. The adapter now reads those same fields and offers the lexer the rest of the buffer as a +window, as for a stream. It consumes exactly the characters `getc_unlocked()` would have returned, so the file is +left where it was before, and when the C library is about to refill the buffer, the part of the current token in it is +kept for error messages. The idea is gnulib's +[`freadptr()`](https://github.com/coreutils/gnulib/blob/master/lib/freadptr.c), which knows these fields for many C +libraries. Parsing from a `FILE*` got another 2.0–2.6× faster on the M1 and 1.7–2.2× with glibc on x86-64, and +`accept()` 2.9–4.4×; it is now about as fast as parsing from an `std::ifstream`. uClibc +defines `__GLIBC__` but has other fields, so it is excluded, and other C libraries keep reading one character at a +time. + +Two details: MemorySanitizer builds keep `fgetc`, because glibc's inline `getc_unlocked` reads a buffer that +uninstrumented glibc filled. And a SAX handler that waits for another thread using the same `FILE` now deadlocks, as the +documentation says. Both paths can be switched off, with [`JSON_DISABLE_STREAM_BULK_SCAN`](../../api/macros/json_disable_stream_bulk_scan.md) and -[`JSON_DISABLE_UNLOCKED_FILE_IO`](../../api/macros/json_disable_unlocked_file_io.md). +[`JSON_DISABLE_UNLOCKED_FILE_IO`](../../api/macros/json_disable_unlocked_file_io.md); the second turns the `FILE` +window off as well, as it needs the lock for the whole parse. ## 2 Work on words, not bytes { #theme-2 } @@ -325,6 +381,20 @@ Hacks](https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord). The sub it can flag a byte *after* a real hit, but never one before it: the lowest flag is always right, and the number of trailing zero bits divided by eight is the index of the first special byte. +The code above is the readable form, and it does more work than it needs to. The `& ~x` of the zero-byte test only +keeps bytes from 0x80 out of the result, and `has_non_ascii` flags exactly those bytes anyway. So all three `& ~x` +terms can go, and the whole test becomes one expression: + +```cpp +return ((q - ones) | (b - ones) | (v - 0x2020202020202020ull) | v) & high; +``` + +

The same test without the redundant `& ~x` terms.

+ +Clang finds this reduction on its own; GCC doesn't, and emitted 24 instructions per word instead of 16. With GCC on +x86-64, writing it out made validating `twitter.json` and `citm_catalog.json` 5–7% faster and `dump()` 2–3%. The +masks of both forms are the same for every pair of adjacent byte values and for 1.3 billion random words. +
--8<-- "images/perf_blog_swar.svg"
@@ -359,10 +429,16 @@ The same kind of word test now runs in several places: ### 16 bytes with SSE2 and NEON Where a vector unit is part of the baseline instruction set (SSE2 on x86-64, NEON on 64-bit Arm), the string paths take -16 bytes per step. Two well-known details are worth repeating. A signed comparison `byte < 0x20` catches control -characters *and* every byte from 0x80, because those are negative as `int8_t`, so four classes of bytes take three -comparisons. And NEON has no equivalent of x86's `pmovmskb`, which turns a vector of comparison results into a bit mask. -The usual replacement, by [Danila +16 bytes per step. Three details are worth repeating. A signed comparison `byte < 0x20` catches control characters +*and* every byte from 0x80, because those are negative as `int8_t`. Flipping bit 1 first folds the quote into that +comparison too: `c ^ 2` maps 0x00–0x1F onto themselves and the quote (0x22) onto 0x20, keeps the bytes from 0x80 +negative, and maps every other ASCII byte to 33 or more. So `(int8_t)(c ^ 2) < 33` finds three of the four classes of +bytes, and only the backslash needs a comparison of its own (Daniel Lemire, ["Detect control characters, quotes and +backslashes efficiently using +SWAR"](https://lemire.me/blog/2025/04/13/detect-control-characters-quotes-and-backslashes-efficiently-using-swar/), +2025; ruby/json uses the same trick in its SIMD scan). Neither GCC nor Clang finds this one on its own. And NEON has no +equivalent of x86's `pmovmskb`, which turns a vector of comparison results into a bit mask. The usual replacement, by +[Danila Kutenin](https://developer.arm.com/community/arm-community-blogs/b/servers-and-cloud-computing-blog/posts/porting-x86-vector-bitmask-optimizations-to-arm-neon), shifts each 16-bit lane right by four and narrows it to eight bits, which leaves four bits per input byte in a 64-bit integer: @@ -371,10 +447,11 @@ integer: while (e - p >= 16) { const uint8x16_t in = vld1q_u8(p); - const uint8x16_t quote_or_backslash = vorrq_u8(vceqq_u8(in, vdupq_n_u8('"')), - vceqq_u8(in, vdupq_n_u8('\\'))); - const uint8x16_t special = vorrq_u8(quote_or_backslash, - vcltq_s8(vreinterpretq_s8_u8(in), vdupq_n_s8(0x20))); + // the backslash, and (c ^ 2) < 33 as int8_t for the quote, the control + // characters and the bytes from 0x80 + const uint8x16_t special = vorrq_u8(vceqq_u8(in, vdupq_n_u8('\\')), + vcltq_s8(vreinterpretq_s8_u8(veorq_u8(in, vdupq_n_u8(2))), + vdupq_n_s8(33))); // each byte of special is 0x00 or 0xFF; shifting each 16-bit lane right by 4 and // keeping its low byte leaves 4 bits per input byte const uint8x8_t nibbles = vshrn_n_u16(vreinterpretq_u16_u8(special), 4); @@ -387,7 +464,7 @@ while (e - p >= 16) } ``` -

The NEON string scan: three comparisons for four classes of bytes, and a narrowing +

The NEON string scan: two comparisons for four classes of bytes, and a narrowing shift in place of `pmovmskb`.

On `twitter.json`, the vector scan made `json::parse()` 5% faster with Clang and 9% with GCC on an M1 Max; on long @@ -738,7 +815,12 @@ arrays `[1,2]` at most 1.5%. The parser checks for the expected `:` or `,` before it falls into the lexer's general token switch (−7% on `citm_catalog.json`, a contribution by [@mvanslobbe](https://github.com/mvanslobbe)), and [`to_cbor()`](../../api/basic_json/to_cbor.md) no longer creates a temporary `basic_json` for every object key it writes -(−55% on `twitter.json`, by [@alexprabhat99](https://github.com/alexprabhat99)). +(−55% on `twitter.json`, by [@alexprabhat99](https://github.com/alexprabhat99)). And the binary readers wrote every +byte of a string twice: they grew the string with `resize()`, which fills it with zeros, and then copied the bytes over +the zeros. The zero fill alone was 5% of the instructions of `from_cbor()`. A string that the input holds in one block +is now appended from it directly, after its claimed length has been checked against the input, so a huge claimed +length still allocates nothing. Reading `twitter.json` and `citm_catalog.json` from CBOR and MessagePack got 5–9% +faster on the M1, 3–9% on x86-64. !!! note "The same idea, measured twice" @@ -1006,6 +1088,15 @@ oa.commit(out - start);

Encoding a run of integers through a cursor, with one capacity check for the whole run.

+The cursor needs the vector to be as long as the space it writes into, so the sink grew it with `resize()` to its whole +new capacity. That zeroes the new bytes, so every doubling zeroed megabytes of fresh memory at once: it faulted in all +the pages and pushed them out of the cache before the writer got there. The writers had gained much less on x86-64 than +on the M1 (writing an array of numbers as CBOR got 2.4× as fast on a Skylake laptop and 4.8× on the M1 Max), and this +was part of the reason: page faults, zeroing and copies on reallocation of a 9–80 MB output were 30–76% of the 3.13.0 +time on x86-64. The sink still reserves the same capacity, but now resizes the vector only to 64 KiB ahead of the +cursor, a window that is still in the cache when the writer's stores arrive. On x86-64, writing large arrays of numbers +got 9–26% faster; on the M1, 1–2.5%. + ### Integers without branches The last step also took the branches out of encoding an integer. After the bounds checks, mispredicted branches @@ -2566,19 +2657,21 @@ suite also on an Intel laptop with Clang. The comparison with other libraries al which Haswell doesn't have, so the real CPU is probably a later one. It shares its cores with other work, so its differences of a few percent are noise. The theme sections quote x86-64 numbers where a pull request measured them, mostly with GCC 13 and Clang 18. Expect smaller gains for some workloads on x86-64 with glibc: its `FILE*` locking is -cheaper to begin with, and its allocator behaves differently. We did not measure on Windows, and we did not measure -binary size or memory use. +cheaper to begin with, so taking it once per parse gains less, its allocator behaves differently, and fresh output +memory costs more page faults. We did not measure on Windows, and we did not measure binary size or memory use. ### H. Pull requests by theme - 1 **Specialize for each input source:** contiguous input - [#5283][5283]; streams and `FILE*` [#5828][5828], [#5884][5884]; binary strings in bulk [#5233][5233]; sized sentinels - [#5268][5268]. + [#5283][5283]; streams and `FILE*` [#5828][5828], [#5884][5884]; the buffer of a `FILE` [#6030][6030]; iterator + pairs [#6029][6029]; one loop for parser and lexer [#6031][6031]; binary strings in bulk [#5233][5233]; sized + sentinels [#5268][5268]. - 2 **Work on words, not bytes:** word-at-a-time strings, UTF-8 runs, and digits [#5283][5283], [#5738][5738], [#5851][5851]; indentation [#5810][5810]; `dump()` runs [#5285][5285]; SSE2 and NEON [#5853][5853], [#5628][5628]; `json_document` with `ensure_ascii` [#5879][5879]; short strings [#5878][5878], [#5883][5883]; escape table [#5822][5822]; loop shape [#5490][5490]; fraction digits [#5971][5971]; - BSON keys [#5968][5968]; UTF-8 checks of binary strings [#5982][5982]; one SIMD macro set [#5921][5921]. + BSON keys [#5968][5968]; UTF-8 checks of binary strings [#5982][5982]; one SIMD macro set [#5921][5921]; fewer + operations per word and vector [#6033][6033]. - 3 **Own the number conversions:** Eisel-Lemire [#5617][5617]; long numbers [#5738][5738]; one pass over the digits [#5851][5851]; Żmij [#5634][5634]; long integers in `dump()` [#5964][5964]; MSVC intrinsics for the 128-bit multiplication [#5782][5782]. @@ -2587,13 +2680,14 @@ binary size or memory use. [#5501][5501]; `ordered_json` growth [#5609][5609]; arrays at their final size [#5882][5882]; moving keys [#5458][5458], [#5855][5855]; separator check [#5592][5592]; CBOR keys [#5328][5328]; `accept()` without copies [#5978][5978]; runs of scalars in binary arrays [#5975][5975], [#5980][5980], [#5981][5981], [#6008][6008]; BJData - marker list [#5555][5555]; `from_json()` into containers [#5833][5833]. + marker list [#5555][5555]; `from_json()` into containers [#5833][5833]; binary strings without a zero fill + [#6028][6028]. - 5 **Read the assembly:** stack canary [#5854][5854]; exceptions and inlining [#5838][5838], [#5840][5840], [#5839][5839], [#5836][5836]; aliasing [#5633][5633]; forced inlining [#5878][5878]; branch-free selections [#5908][5908]; code size [#5902][5902]. - 6 **Write through a cursor:** write buffer [#5285][5285]; adapter on the stack [#5449][5449]; binary sinks [#5286][5286]; cursor and runs of numbers [#5824][5824], - [#5885][5885]; `to_bson()` in one pass [#5979][5979]. + [#5885][5885]; `to_bson()` in one pass [#5979][5979]; a window instead of zeroing the whole capacity [#6020][6020]. - 7 **Fix the algorithm first:** `diff()` [#5461][5461]; parse callbacks [#5457][5457]; `operator<` [#5390][5390]; `flatten()` [#5834][5834]; `unflatten()` [#5793][5793]; JSON Pointer and Patch [#5861][5861], [#5886][5886]; `to_bson()` [#5553][5553]; `JSON_DIAGNOSTICS` [#5832][5832]; bounded @@ -2681,7 +2775,15 @@ These are the checks behind the themes: [Appendix F](#f-behavior-changes). - **Stream windows** (theme 1). A test `streambuf` hands out get areas of 1–4,096 bytes, so that every token crosses a window edge at every offset, and the result is compared with a - `streambuf` without a get area, which takes the old path. + `streambuf` without a get area, which takes the old path. The `FILE` window is checked the same way, with `FILE` + buffers of 1 to 70 bytes, unbuffered files, and characters pushed back with `ungetc()` before the parse, on macOS and + glibc. +- **The fused parser** (theme 1). `contiguous_parser` is + compared with the byte path on corner cases of literals, numbers, escapes, surrogates and UTF-8, on 1,500 generated + documents, every truncation of the short ones and random mutations, and on nesting across the 64-level words of its + stack: the same values with the same number types and float bits, the same exception messages, the same `accept()` + verdicts and the same `sax_parse()` events, also for handlers that stop early (242,118 assertions). A fuzzer that + compares the `sax_parse()` events of both paths ran 9 million inputs without a finding. - **Differential fuzzing of the input kinds.** Every fuzz input is also parsed from an `std::istringstream`, from `std::list` iterators (the byte path) and from a `FILE*`, and the value, the exception id, the message, and the byte offset must be identical ([#5827][5827]). @@ -2792,3 +2894,9 @@ These are the checks behind the themes: [5981]: https://github.com/nlohmann/json/pull/5981 [5982]: https://github.com/nlohmann/json/pull/5982 [6008]: https://github.com/nlohmann/json/pull/6008 +[6020]: https://github.com/nlohmann/json/pull/6020 +[6028]: https://github.com/nlohmann/json/pull/6028 +[6029]: https://github.com/nlohmann/json/pull/6029 +[6030]: https://github.com/nlohmann/json/pull/6030 +[6031]: https://github.com/nlohmann/json/pull/6031 +[6033]: https://github.com/nlohmann/json/pull/6033