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 56883c0ed..7da37b59c 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 @@ -1,6 +1,7 @@ # How nlohmann/json 3.13.0 got 3× faster -
+ |"other iterators,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.
+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.
### Detecting the input in C++11 -An input adapter can declare what it supports as static members (`supports_seek`, -`supports_bulk_scan`, and a few more). The lexer detects them with the detection idiom, so a user's adapter that -declares nothing keeps the byte path: +An input adapter can declare what it supports as static members (`supports_seek`, `supports_bulk_scan`, and a few more). +The lexer detects them with the detection idiom, so a user's adapter that declares nothing keeps the byte path: ```cpp templateDetecting a static member of the input adapter with the detection idiom: an adapter that doesn't declare `supports_seek` is not seekable.
+Detecting a static member of the input adapter with the detection idiom: an adapter +that doesn't declare `supports_seek` is not seekable.
Without `if constexpr`, the flags select overloads by tag dispatch, `f(std::integral_constantReaching the protected get area of any `std::streambuf` through pointers to members, without a cast.
+Reaching the protected get area of any `std::streambuf` through pointers to members, +without a cast.
A derived class may name a protected member of its base in a pointer to member, as long as it qualifies the name with its own class. The resulting pointer has the type `char* (std::streambuf::*)() const`, and access is checked when the @@ -257,20 +273,20 @@ name is used, not when the pointer is called, so it works on any `std::streambuf `static_castThe eight-byte test of the string scanner, in portable C++11.
-XOR with the quote turns every quote byte into zero, and `(x - 0x01…01) & ~x & 0x80…80` sets the high bit of the -bytes that were zero: the "determine if a word has a zero byte" trick from Sean Eron Anderson's -[Bit Twiddling Hacks](https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord). The subtraction's borrow -runs only upwards, so 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. +XOR with the quote turns every quote byte into zero, and `(x - 0x01…01) & ~x & 0x80…80` sets the high bit of the bytes +that were zero: the "determine if a word has a zero byte" trick from Sean Eron Anderson's [Bit Twiddling +Hacks](https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord). The subtraction's borrow runs only upwards, so +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.`swar_string_special()` on the last eight bytes of `"twitter",`: one word in, one flag out, and the count of trailing zero bits says how many bytes can be copied before the quote.
+`swar_string_special()` on the last eight bytes of `"twitter",`: one word in, one flag +out, and the count of trailing zero bits says how many bytes can be copied before the quote.
Two details make this portable C++11. The word is loaded with `std::memcpy`, which compilers turn into one load and which, unlike a `reinterpret_cast`, doesn't break the aliasing rules. And the load always produces the bytes in @@ -324,15 +340,15 @@ remain before it loads, and finishes the last zero to seven bytes one at a time, The same kind of word test now runs in several places: -- **Strings in the lexer,** including runs of well-formed multi-byte UTF-8, which are validated one sequence after - the other without going back to the byte loop. +- **Strings in the lexer,** including runs of well-formed multi-byte UTF-8, which are validated one sequence after the + other without going back to the byte loop. - **Numbers,** eight digits per step (theme 3). - **Indentation.** After a newline, `word ^ 0x2020…20` is zero for eight spaces; the first non-zero byte ends the indentation, and its trailing-zero count says how many spaces of the last word to skip as well. The idea is from - [Glaze](https://github.com/stephenberry/glaze)'s `skip_matching_ws`, which compares with the previous line's indentation; comparing with spaces needs no - state and works for any indentation width. A version that skipped only whole words validated 5–12% slower and gave - nothing on two-space indentation, where the runs are often shorter than a word. With four-space indentation, - validating `citm_catalog.json` got 22–30% faster. + [Glaze](https://github.com/stephenberry/glaze)'s `skip_matching_ws`, which compares with the previous line's + indentation; comparing with spaces needs no state and works for any indentation width. A version that skipped only + whole words validated 5–12% slower and gave nothing on two-space indentation, where the runs are often shorter than a + word. With four-space indentation, validating `citm_catalog.json` got 22–30% faster. - **[`dump()`](../../api/basic_json/dump.md),** for runs of characters that need no escaping, with and without [`ensure_ascii`](../../api/basic_json/dump.md#parameters). - **The binary formats,** for the zero byte that ends a BSON key, and for the UTF-8 check of strings with @@ -341,14 +357,14 @@ 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 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: +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 +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: ```cpp while (e - p >= 16) @@ -370,42 +386,45 @@ while (e - p >= 16) } ``` -The NEON string scan: three comparisons for four classes of bytes, and a narrowing shift in place of `pmovmskb`.
+The NEON string scan: three 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 -strings, 19–24%. It did not win from the first byte. On the M1, starting with NEON made short strings 3–4% -slower and the rest of the lexer bigger. So ARM runs four 8-byte words first and keeps the vector loop out of line, -while x86-64 runs one word first. Inlining the vector code into the lexer cost 5% of validating `jeopardy.json` on the M1, -although no string of that file is long enough to reach it: the scan function, which runs once per token, saved more -registers and needed a stack slot. +strings, 19–24%. It did not win from the first byte. On the M1, starting with NEON made short strings 3–4% slower and +the rest of the lexer bigger. So ARM runs four 8-byte words first and keeps the vector loop out of line, while x86-64 +runs one word first. Inlining the vector code into the lexer cost 5% of validating `jeopardy.json` on the M1, although +no string of that file is long enough to reach it: the scan function, which runs once per token, saved more registers +and needed a stack slot. -[`json_document`](../../features/json_view.md), the new document type of `json_view.hpp`, also skips the digits after a decimal point 16 bytes at a time with SSE2: a compare and a bit mask -instead of a branch per digit, about 33% of the instructions for a 12-digit fraction. Parsing `canada.json` got -22% faster on x86-64, a file of 10,000 floats 28–32%. One row got slower: with GCC 16, `citm_catalog.json` took -8% longer with the same number of instructions. More of its hot jumps now crossed a 32-byte boundary, which the -microcode fix for Intel's JCC erratum makes slow on our server; assembling with -`-Wa,-mbranches-within-32B-boundaries` shrinks the loss to about 4%. We didn't change the code, and all numbers in this post are measured without that flag. +[`json_document`](../../features/json_view.md), the new document type of `json_view.hpp`, also skips the digits after a +decimal point 16 bytes at a time with SSE2: a compare and a bit mask instead of a branch per digit, about 33% of the +instructions for a 12-digit fraction. Parsing `canada.json` got 22% faster on x86-64, a file of 10,000 floats 28–32%. +One row got slower: with GCC 16, `citm_catalog.json` took 8% longer with the same number of instructions. More of its +hot jumps now crossed a 32-byte boundary, which the microcode fix for Intel's JCC erratum makes slow on our server; +assembling with `-Wa,-mbranches-within-32B-boundaries` shrinks the loss to about 4%. We didn't change the code, and all +numbers in this post are measured without that flag. ### Validating UTF-8 16 bytes at a time -Validating UTF-8 16 bytes at a time uses [simdjson](https://github.com/simdjson/simdjson)'s "lookup4" algorithm -([Keiser and Lemire, 2021](https://arxiv.org/abs/2010.03090)): three -16-entry tables, indexed by nibbles of a byte and of the byte before it, each give the set of errors that nibble -allows, and a pair of bytes is invalid if all three sets share an error. It needs `pshufb`, which is SSSE3 and not part -of baseline x86-64. The function is compiled with `__attribute__((target("ssse3")))`, out of line, and called only if -`cpuid` says so. The result of that query is cached in a constant-initialized `static std::atomicThe code above on `profile_link_color`, a key of `twitter.json`.
-On `twitter.json`, this cut the instructions of `dump()` by 21%. Shorter strings use the same trick with two -overlapping 8-, 4-, or 2-byte loads, so no `memcpy` with a length known only at run time is left. +On `twitter.json`, this cut the instructions of `dump()` by 21%. Shorter strings use the same trick with two overlapping +8-, 4-, or 2-byte loads, so no `memcpy` with a length known only at run time is left. ### Escapes from a table -When the scanner stops at a quote, a backslash, or a control character, the -writer copies its escape sequence from a table of 8-byte rows, padded with zeros, with the length in the last byte: +When the scanner stops at a quote, a backslash, or a control character, the writer copies its escape sequence from a +table of 8-byte rows, padded with zeros, with the length in the last byte: ```cpp {{'\\', 'u', '0', '0', '0', '0', 0, 6}}, // 0x00 @@ -445,28 +464,29 @@ std::memcpy(write_buffer.data() + write_buffer_pos, escape.data(), 8); // alwa write_buffer_pos += escape[7]; // keep only the real length ``` -Three rows of the escape table, and how a row is written: always 8 bytes, then the real length.
+Three rows of the escape table, and how a row is written: always 8 bytes, then the +real length.
One 8-byte store and one add, with no branch on the length; the padding lies behind the write position and is -overwritten by the next write. The writer makes sure that 8 bytes are free before it copies. The idea is from [orjson](https://github.com/ijl/orjson)'s -`QUOTE_TAB`, which orjson took from [sonic-rs](https://github.com/cloudwego/sonic-rs). Although dumping 100,000 log lines full of `\n`, `\t`, and quotes got 23% -faster on an M1 Max, most of that came from writing straight into the write buffer instead of an intermediate one. +overwritten by the next write. The writer makes sure that 8 bytes are free before it copies. The idea is from +[orjson](https://github.com/ijl/orjson)'s `QUOTE_TAB`, which orjson took from +[sonic-rs](https://github.com/cloudwego/sonic-rs). Although dumping 100,000 log lines full of `\n`, `\t`, and quotes got +23% faster on an M1 Max, most of that came from writing straight into the write buffer instead of an intermediate one. The table alone gained 2–7%. ## 3 Own the number conversions { #theme-3 } -3.12.0 converted every floating-point number with `strtod` and every integer with `strtoull` or `strtoll`, after -copying the token into a buffer. `strtod` is correct, but general: it consults the locale for the decimal point, sets -`errno`, and handles any length of input. `std::from_chars` would avoid the locale, but it doesn't exist before C++17, -and libc++ has its floating-point overloads without defining `__cpp_lib_to_chars`, so macOS builds could not detect -it. +3.12.0 converted every floating-point number with `strtod` and every integer with `strtoull` or `strtoll`, after copying +the token into a buffer. `strtod` is correct, but general: it consults the locale for the decimal point, sets `errno`, +and handles any length of input. `std::from_chars` would avoid the locale, but it doesn't exist before C++17, and libc++ +has its floating-point overloads without defining `__cpp_lib_to_chars`, so macOS builds could not detect it. `canada.json`, a GeoJSON file of 111,126 numbers, shows what that costs. 108,528 of its numbers have 16 or 17 -significant digits, like `-65.613616999999977`. The classic shortcut is Clinger's fast path -([Clinger, 1990](https://doi.org/10.1145/93542.93557)): write the number as its digits `w` times a power of ten, -convert `w` to a `double`, and multiply or divide it by that power of ten. If both are exact as `double`, that is, `w` -is at most 2⁵³ (9,007,199,254,740,992) and the power at most 10²², the operation rounds only once, and the result is -the correctly rounded `double`. For most of `canada.json`'s numbers, `w` doesn't fit into 53 bits. +significant digits, like `-65.613616999999977`. The classic shortcut is Clinger's fast path ([Clinger, +1990](https://doi.org/10.1145/93542.93557)): write the number as its digits `w` times a power of ten, convert `w` to a +`double`, and multiply or divide it by that power of ten. If both are exact as `double`, that is, `w` is at most 2⁵³ +(9,007,199,254,740,992) and the power at most 10²², the operation rounds only once, and the result is the correctly +rounded `double`. For most of `canada.json`'s numbers, `w` doesn't fit into 53 bits. 3.13.0 converts `float` and `double` numbers itself, without the C library and without the locale: @@ -482,13 +502,13 @@ flowchart TB amb -->|"no"| big["compare with the exact midpointHow 3.13.0 converts a decimal token to a `double`. Of `canada.json`'s 111,080 floating-point numbers, 9,742 take Clinger's fast path and 101,338 Eisel-Lemire; none has more than 19 digits.
+How 3.13.0 converts a decimal token to a `double`. Of `canada.json`'s 111,080 +floating-point numbers, 9,742 take Clinger's fast path and 101,338 Eisel-Lemire; none has more than 19 digits.
### Eight digits at a time -On contiguous input and stream windows, the lexer accumulates the value while it validates the token, so the digits -are read once. -The core is [fast_float](https://github.com/fastfloat/fast_float)'s eight-digit conversion: three +On contiguous input and stream windows, the lexer accumulates the value while it validates the token, so the digits are +read once. The core is [fast_float](https://github.com/fastfloat/fast_float)'s eight-digit conversion: three multiplications instead of eight. ```cpp @@ -502,19 +522,21 @@ std::uint32_t parse_eight_digits(std::uint64_t v) noexcept } ``` -fast_float's conversion of eight ASCII digits to their value, with three multiplications.
+fast_float's conversion of eight ASCII digits to their value, with three +multiplications.
`parse_eight_digits()` on `"12345678"`: multiplying by `10·2⁸ + 1` adds ten times each byte to the byte above it, and the shift and the next mask keep every second lane.
+`parse_eight_digits()` on `"12345678"`: multiplying by `10·2⁸ + 1` adds ten times each +byte to the byte above it, and the shift and the next mask keep every second lane.
-Checking that all eight bytes are digits takes one more expression. A digit is a byte from 0x30 to 0x39. In -hexadecimal, its upper half is 3 and its lower half is at most 9. Adding 6 tests the lower half: 0x39 + 6 = 0x3F still -has the upper half 3, but 0x3A + 6 = 0x40 carries into it. So a byte is a digit exactly if its upper half is 3 both -before and after adding 6, and with masks, that is one comparison for all eight bytes. An integer of up to 18 digits -always fits into an `int64_t`, and 19 digits into a `uint64_t`, so they need no overflow check. +Checking that all eight bytes are digits takes one more expression. A digit is a byte from 0x30 to 0x39. In hexadecimal, +its upper half is 3 and its lower half is at most 9. Adding 6 tests the lower half: 0x39 + 6 = 0x3F still has the upper +half 3, but 0x3A + 6 = 0x40 carries into it. So a byte is a digit exactly if its upper half is 3 both before and after +adding 6, and with masks, that is one comparison for all eight bytes. An integer of up to 18 digits always fits into an +`int64_t`, and 19 digits into a `uint64_t`, so they need no overflow check. ### Clinger's fast path @@ -532,43 +554,44 @@ if (exponent >= -22 && exponent <= 22 && w <= (std::uint64_t{1} << 53)) #endif ``` -Clinger's fast path: one correctly rounded multiplication or division, only without x87 extended precision.
+Clinger's fast path: one correctly rounded multiplication or division, only without +x87 extended precision.
With x87 arithmetic (`FLT_EVAL_METHOD != 0`), the intermediate result has extended precision and is rounded twice, so those targets skip the fast path and take the integer-only route below. ### Eisel-Lemire -[Eisel-Lemire](https://arxiv.org/abs/2101.11408), adapted from fast_float's `compute_float`, handles everything else with integers only: it multiplies -the digits with a 128-bit approximation of `5^q` from a table and takes the mantissa from the top of the product. For -up to 19 digits, that always suffices ([Mushtak and Lemire, 2023](https://arxiv.org/abs/2212.06644)). Longer numbers, rare in JSON, are bracketed by -their first 19 digits and that plus one; only if the two round differently are all digits compared with the exact -midpoint. [Appendix I](#i-details-of-the-themes) has the details. Because it is integer arithmetic, the result no -longer depends on the C locale. (JSON always uses `.`, but `strtod` expects the locale's decimal separator, so 3.12.0 -replaced the `.` before calling it, which [failed for separators longer than one byte][5660].) -[Appendix J](#j-validation) describes how the conversion was checked against `strtod`. +[Eisel-Lemire](https://arxiv.org/abs/2101.11408), adapted from fast_float's `compute_float`, handles everything else +with integers only: it multiplies the digits with a 128-bit approximation of `5^q` from a table and takes the mantissa +from the top of the product. For up to 19 digits, that always suffices ([Mushtak and Lemire, +2023](https://arxiv.org/abs/2212.06644)). Longer numbers, rare in JSON, are bracketed by their first 19 digits and that +plus one; only if the two round differently are all digits compared with the exact midpoint. [Appendix +I](#i-details-of-the-themes) has the details. Because it is integer arithmetic, the result no longer depends on the C +locale. (JSON always uses `.`, but `strtod` expects the locale's decimal separator, so 3.12.0 replaced the `.` before +calling it, which [failed for separators longer than one byte][5660].) [Appendix J](#j-validation) describes how the +conversion was checked against `strtod`. ### Writing: Żmij -For output, 3.12.0 used [Grisu2](https://doi.org/10.1145/1806596.1806623), which is fast but sometimes writes a 17th digit where 16 suffice. -3.13.0 ports [Żmij](https://github.com/vitaut/zmij) by Victor Zverovich to C++11. It writes the shortest decimal that -reads back as the same `double`, and the closest one if there are several. Its digit conversion, by Xiang JunBo, is -the parser's divide-by-multiplying idea run backwards on all lanes of a word at once ([Appendix -I](#i-details-of-the-themes) shows it), and with SSE2 or NEON, Żmij's vector code (the NEON version by Dougall Johnson, the SSE version by -Tobias Schlüter) converts all 16 digits in one vector register. +For output, 3.12.0 used [Grisu2](https://doi.org/10.1145/1806596.1806623), which is fast but sometimes writes a 17th +digit where 16 suffice. 3.13.0 ports [Żmij](https://github.com/vitaut/zmij) by Victor Zverovich to C++11. It writes the +shortest decimal that reads back as the same `double`, and the closest one if there are several. Its digit conversion, +by Xiang JunBo, is the parser's divide-by-multiplying idea run backwards on all lanes of a word at once ([Appendix +I](#i-details-of-the-themes) shows it), and with SSE2 or NEON, Żmij's vector code (the NEON version by Dougall Johnson, +the SSE version by Tobias Schlüter) converts all 16 digits in one vector register. One stall turned up on the way. To make room for the decimal point, the code first wrote all 16 digits, as two 8-byte stores, and then moved the digits after the point one byte to the right: it loaded them and stored them one byte further. That load came right after the two stores and read part of each. A CPU can hand a load the data of one store -that hasn't reached the cache yet, but it can't combine two, so the load had to wait until both stores were written. -The commit measured that wait as about 25% of the time to write a `double`. The vector version avoids memory: it -keeps the digits in a register, moves the ones after the point up by one byte there, puts `'.'` into the gap, and -stores all 16 bytes once. +that hasn't reached the cache yet, but it can't combine two, so the load had to wait until both stores were written. The +commit measured that wait as about 25% of the time to write a `double`. The vector version avoids memory: it keeps the +digits in a register, moves the ones after the point up by one byte there, puts `'.'` into the gap, and stores all 16 +bytes once. -In the pull request's measurement, `dump()` of `canada.json` got about 3× as fast, on an M1 Max as well as -on an x86-64 server with GCC and Clang. The changed output is listed in [Appendix F](#f-behavior-changes): 0.14% of random -`double`s are written differently, all of them with the same or fewer digits, and all of them read back as the same -value. +In the pull request's measurement, `dump()` of `canada.json` got about 3× as fast, on an M1 Max as well as on an x86-64 +server with GCC and Clang. The changed output is listed in [Appendix F](#f-behavior-changes): 0.14% of random `double`s +are written differently, all of them with the same or fewer digits, and all of them read back as the same value. ## 4 Don't do unnecessary work { #theme-4 } @@ -576,22 +599,21 @@ A lot of what the 3.12.0 parser did was correct, cheap per call, and read by nob ### The token buffer -The old `get()` of theme 1 pushed every character into `token_string`, a buffer that only the "last -read" part of an error message uses. For seekable input, the lexer now remembers the offset where the token started -and copies the text from the input if an error happens. This is the 35% step of the timeline: in the indented -`citm_catalog.json`, every space of the indentation went through the buffer. For streams, the carry buffer of theme 1 does the same job; the per-character -copy had been about 20% of what was left of a stream parse. +The old `get()` of theme 1 pushed every character into +`token_string`, a buffer that only the "last read" part of an error message uses. For seekable input, the lexer now +remembers the offset where the token started and copies the text from the input if an error happens. This is the 35% +step of the timeline: in the indented `citm_catalog.json`, every space of the indentation went through the buffer. For +streams, the carry buffer of theme 1 does the same job; the +per-character copy had been about 20% of what was left of a stream parse. -[`accept()`](../../api/basic_json/accept.md) went one step further: it doesn't copy strings and keys at all. The -lexer and the parser take a template parameter that drops values, so `accept()` gets its own instantiation without the -copies. That made validating `twitter.json` 11–13% faster on x86-64, and 19 files of simdjson's examples 12% -by geometric mean. +[`accept()`](../../api/basic_json/accept.md) went one step further: it doesn't copy strings and keys at all. The lexer +and the parser take a template parameter that drops values, so `accept()` gets its own instantiation without the copies. +That made validating `twitter.json` 11–13% faster on x86-64, and 19 files of simdjson's examples 12% by geometric mean. ### Positions only on error -For input that can be seeked, the lexer no longer counts lines and columns. -The byte offset is the distance from the start, and lines and columns are recomputed only when an error message needs -them: +For input that can be seeked, the lexer no longer counts lines and columns. The byte offset is the distance from the +start, and lines and columns are recomputed only when an error message needs them: ```cpp std::size_t count_consumed_lines(std::size_t last_index, std::size_t& line_start) const @@ -604,20 +626,22 @@ std::size_t count_consumed_lines(std::size_t last_index, std::size_t& line_start } ``` -Line and column of an error, computed from the byte offset only when an error message needs them.
+Line and column of an error, computed from the byte offset only when an error +message needs them.
-That is linear in the input, but only an error pays for it. It is what [serde_json's slice reader](https://github.com/serde-rs/json/blob/master/src/read.rs) does -(`position_of_index()` with `memrchr` and a newline count). A pending `unget()` is a flag, and the offset subtracts -one for it, so `unget()` does no position work at all. Measuring the two parts of the old `get()` separately on an M1 -showed that the newline branch cost more than the counters: removing only the branch saved 16% of validating an indented -`twitter.json`, removing only the increments 4%. Parsing `citm_catalog.json` got 12% faster. +That is linear in the input, but only an error pays for it. It is what [serde_json's slice +reader](https://github.com/serde-rs/json/blob/master/src/read.rs) does (`position_of_index()` with `memrchr` and a +newline count). A pending `unget()` is a flag, and the offset subtracts one for it, so `unget()` does no position work +at all. Measuring the two parts of the old `get()` separately on an M1 showed that the newline branch cost more than the +counters: removing only the branch saved 16% of validating an indented `twitter.json`, removing only the increments 4%. +Parsing `citm_catalog.json` got 12% faster. ### The text of float tokens -The lexer copied every float token into a buffer so that a SAX handler's -`number_float(val, s)` could see the original text: an out-of-line `std::string::append` per float, about 10% of -parsing `canada.json`. The handlers behind [`parse()`](../../api/basic_json/parse.md) and [`accept()`](../../api/basic_json/accept.md) never look at it. The parser now recognizes them -by their exact type: +The lexer copied every float token into a buffer so that a SAX handler's `number_float(val, s)` could see the original +text: an out-of-line `std::string::append` per float, about 10% of parsing `canada.json`. The handlers behind +[`parse()`](../../api/basic_json/parse.md) and [`accept()`](../../api/basic_json/accept.md) never look at it. The parser +now recognizes them by their exact type: ```cpp templateRecognizing the library's own SAX handlers, which never read the text of a float token.
+Recognizing the library's own SAX handlers, which never read the text of a float +token.
A class derived from `json_sax_dom_parser` does not match the specialization, so it keeps getting the exact text. For every other handler, the lexer only records a pointer to the token and its length, and copies on the first -`get_string()`. That is safe for contiguous input, which has to outlive the parse anyway; in a stream's window, the -next refill could overwrite the text, so there the copy is made at once. The first version put the "is there pending -text?" check into the `get_string()` that keys and strings use too, and lost 2–3% on string-heavy input. Keys and strings now +`get_string()`. That is safe for contiguous input, which has to outlive the parse anyway; in a stream's window, the next +refill could overwrite the text, so there the copy is made at once. The first version put the "is there pending text?" +check into the `get_string()` that keys and strings use too, and lost 2–3% on string-heavy input. Keys and strings now call a separate function without the check. Parsing `canada.json` got 9–11% faster. ### A copy that should have been a move @@ -650,11 +675,11 @@ return res ? result : basic_json(value_t::discarded); // 3.12.0The end of the 3.12.0 binary readers: the conditional operator copies `result`.
-`result` is a local, but the conditional operator's operand is an lvalue expression, and the implicit move on -`return` applies only to a returned name. So every successfully parsed value was deep-copied once and then destroyed. -3.13.0 assigns `discarded` to `result` on failure and returns `result` by name. Decoding a 2 MB CBOR document went -from 26.99 ms to 14.65 ms. The copy was also a stack-overflow risk: the copy constructor recursed once per level, for -a value the reader had already accepted. +`result` is a local, but the conditional operator's operand is an lvalue expression, and the implicit move on `return` +applies only to a returned name. So every successfully parsed value was deep-copied once and then destroyed. 3.13.0 +assigns `discarded` to `result` on failure and returns `result` by name. Decoding a 2 MB CBOR document went from 26.99 +ms to 14.65 ms. The copy was also a stack-overflow risk: the copy constructor recursed once per level, for a value the +reader had already accepted. ### `ordered_json` growth @@ -682,47 +707,45 @@ Container::swap(tmp); The new element is constructed before any value is moved, so `j.emplace("new", std::move(j["old"]))` still works, and the strong guarantee holds without a `try`, which matters because the library also builds with `-fno-exceptions`. -Parsing `twitter.json` into `ordered_json` needs 33% of the allocations and, in the pull request's -measurement, about 50% of the time. Unfortunately, compilers with the EDG front end (Intel's icpc, NVIDIA's -nvc++) consider that pair's move constructor `noexcept`. There, the vector already "moves", the new path is not -taken, and a key copy that throws during growth calls `std::terminate`. +Parsing `twitter.json` into `ordered_json` needs 33% of the allocations and, in the pull request's measurement, about +50% of the time. Unfortunately, compilers with the EDG front end (Intel's icpc, NVIDIA's nvc++) consider that pair's +move constructor `noexcept`. There, the vector already "moves", the new path is not taken, and a key copy that throws +during growth calls `std::terminate`. ### Containers built one element at a time -The DOM parser used to `push_back` every element into its array, and -insert every member into its object as soon as it was parsed. Now all open arrays share one stack of elements; when -an array of up to 16 elements ends, it is constructed at its exact size from a range of move iterators, one -allocation. `ordered_json` objects collect up to 32 members the same way, with a linear duplicate-key check over that -short slice, and larger objects get a side table of hashes for that check from 64 members on. An `ordered_json` object -with very many members went from 43 million instructions to 1.9 million, and `ordered_json` now parses 1–15% -*faster* than `json` in our suite, since `json`'s `std::map` still allocates a node per member. The binary readers -know their sizes up front and use a separate handler without the stack; sharing it had made them 5–12% slower -with GCC. +The DOM parser used to `push_back` every element into its array, and insert every member into its object as soon as it +was parsed. Now all open arrays share one stack of elements; when an array of up to 16 elements ends, it is constructed +at its exact size from a range of move iterators, one allocation. `ordered_json` objects collect up to 32 members the +same way, with a linear duplicate-key check over that short slice, and larger objects get a side table of hashes for +that check from 64 members on. An `ordered_json` object with very many members went from 43 million instructions to 1.9 +million, and `ordered_json` now parses 1–15% *faster* than `json` in our suite, since `json`'s `std::map` still +allocates a node per member. The binary readers know their sizes up front and use a separate handler without the stack; +sharing it had made them 5–12% slower with GCC. -The binary readers also stopped taking every element through their general dispatch. When an array opens, the -CBOR and MessagePack readers read the numbers, booleans, and nulls that follow in one loop, and the UBJSON and BJData -readers do the same for arrays with a type marker. Reading an array of a million integers from CBOR or MessagePack -got 42–47% faster, and `canada.json` 10%. Arrays of one element skip the loop: in 1,000 arrays nested in each -other, each entered it only to find another array, which cost 6–8%. Now the loop costs nested arrays nothing -measurable, and 100,000 arrays `[1,2]` up to 1.5%. +The binary readers also stopped taking every element through their general dispatch. When an array opens, the CBOR and +MessagePack readers read the numbers, booleans, and nulls that follow in one loop, and the UBJSON and BJData readers do +the same for arrays with a type marker. Reading an array of a million integers from CBOR or MessagePack got 42–47% +faster, and `canada.json` 10%. Arrays of one element skip the loop: in 1,000 arrays nested in each other, each entered +it only to find another array, which cost 6–8%. Now the loop costs nested arrays nothing measurable, and 100,000 arrays +`[1,2]` up to 1.5%. ### Smaller ones -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)). +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)). !!! note "The same idea, measured twice" The SAX interface hands the lexer's buffer to the handler as `string_t&` and allows it to be moved from; the lexer starts each token with an empty buffer anyway. In September, moving string values instead of copying them saved - 2–3%. We measured moving keys too, and dropped it. The buffer is sized for the longest token so far, and - keys are short while values are long. So moving a key hands a large buffer to a short string, and the next value - has to grow a new one: 11.9% slower on 40,000 short strings. In October, with much of the lexer around it rewritten, keys were - measured again: `parse()` of `twitter.json` got 3.3% faster with Clang and 5.0% with GCC, so keys are moved - now too. An idea that lost once is worth measuring again when the code around it changes. + 2–3%. We measured moving keys too, and dropped it. The buffer is sized for the longest token so far, and keys are + short while values are long. So moving a key hands a large buffer to a short string, and the next value has to grow + a new one: 11.9% slower on 40,000 short strings. In October, with much of the lexer around it rewritten, keys were + measured again: `parse()` of `twitter.json` got 3.3% faster with Clang and 5.0% with GCC, so keys are moved now too. + An idea that lost once is worth measuring again when the code around it changes. ## 5 Read the assembly { #theme-5 } @@ -731,8 +754,8 @@ profiles, and instruction counts. None of the following shows up in the source a ### A stack canary on every token -The function that dispatches on the first byte of a token built the expected text -of `true`, `false`, and `null` in local arrays: +The function that dispatches on the first byte of a token built the expected text of `true`, `false`, and `null` in +local arrays: ```cpp case 't': // 3.12.0 @@ -746,11 +769,11 @@ case 't': // 3.12.0 With `-fstack-protector-strong`, which Ubuntu's GCC enables by default and many distributions use for their packages, *any* local array gives a function a stack canary: the prologue loads a secret (on x86-64 Linux from thread-local -storage) and stores it next to the locals, and every return compares it. Even worse, that happens on every call, also for the `[`, `,` and `"` tokens that never touch the -array. The canary's load alone was 11% of the function's samples. (Plain `-fstack-protector` only protects character arrays of -at least 8 bytes, so it didn't trigger here.) Now the literal is a string literal whose length is a template -parameter, compared on contiguous input with one `memcmp` of constant size, and the dispatch has no canary and no array on the -stack: +storage) and stores it next to the locals, and every return compares it. Even worse, that happens on every call, also +for the `[`, `,` and `"` tokens that never touch the array. The canary's load alone was 11% of the function's samples. +(Plain `-fstack-protector` only protects character arrays of at least 8 bytes, so it didn't trigger here.) Now the +literal is a string literal whose length is a template parameter, compared on contiguous input with one `memcmp` of +constant size, and the dispatch has no canary and no array on the stack: ```cpp case 't': return scan_literal("rue", token_type::literal_true); @@ -761,15 +784,15 @@ JSON_HEDLEY_NEVER_INLINE token_type scan_literal(const char (&rest)[N], token_tyThe 3.13.0 dispatch: a string literal whose length is a template parameter.
-`scan_literal()` is deliberately out of line: inlined, it made Clang's dispatch grow from 233 to 278 instructions, -and slower. Together with the next change, validating `canada.json` got 10.5% faster with GCC on x86-64. The same change replaced the parser's -`std::vectorThe throw in 3.12.0's `get_ref()`: building the message makes the function too big to inline.
+The throw in 3.12.0's `get_ref()`: building the message makes the function too big +to inline.
-That made [`get_ref()`](../../api/basic_json/get_ref.md), [`operator[]`](../../api/basic_json/operator%5B%5D.md), [`at()`](../../api/basic_json/at.md) and the -iterator operators too big to inline, and gave callers large -frames (192 bytes for `get_ref()` with GCC). 3.13.0 moves construction and `throw` into helpers marked -`[[noreturn]]` and never inlined, so the hot function keeps a test and a call: +That made [`get_ref()`](../../api/basic_json/get_ref.md), [`operator[]`](../../api/basic_json/operator%5B%5D.md), +[`at()`](../../api/basic_json/at.md) and the iterator operators too big to inline, and gave callers large frames (192 +bytes for `get_ref()` with GCC). 3.13.0 moves construction and `throw` into helpers marked `[[noreturn]]` and never +inlined, so the hot function keeps a test and a call: ```cpp if (JSON_HEDLEY_LIKELY(ptr != nullptr)) { return *ptr; } @@ -791,22 +815,22 @@ throw_incompatible_reference_type(obj); // JSON_HEDLEY_NO_RETURN JSON_HEDLEY_NThe same function in 3.13.0: a test and a call of a helper that is never inlined.
-That fixed 27 throw sites. Iterator arithmetic got 6× as fast with Clang, and `std::sort` over a `json` array of -10,000 integers in user code 6–9% faster. The value's own lifecycle had the same problem: the destructor, the copy -constructor, and [`operator==`](../../api/basic_json/operator_eq.md) contained the code for every type, so they were too big to inline even for a number. Clang -reported about 380 call sites where they were not inlined. Now the destructor and the copy constructor start with an -inline test whether the value owns memory, which Clang compiles to one bit test (`1 << type & 0x10E`), `operator==` -with one for two scalars of the same type, and the rest is out of line. `j == 5` went from 3.55 ns to -0.65 ns with Clang. It is not free: the probe translation unit grew by 13% at `-O2`, and with GCC, `operator==` on -large documents got about 7% slower. +That fixed 27 throw sites. Iterator arithmetic got 6× as fast with Clang, and `std::sort` over a `json` array of 10,000 +integers in user code 6–9% faster. The value's own lifecycle had the same problem: the destructor, the copy constructor, +and [`operator==`](../../api/basic_json/operator_eq.md) contained the code for every type, so they were too big to +inline even for a number. Clang reported about 380 call sites where they were not inlined. Now the destructor and the +copy constructor start with an inline test whether the value owns memory, which Clang compiles to one bit test +(`1 << type & 0x10E`), `operator==` with one for two scalars of the same type, and the rest is out of line. `j == 5` +went from 3.55 ns to 0.65 ns with Clang. It is not free: the probe translation unit grew by 13% at `-O2`, and with GCC, +`operator==` on large documents got about 7% slower. ### Aliasing C++ allows a `char` to access any object, so a store through a `char` pointer may change any object whose address the compiler can't rule out. In a writer that keeps its write position `pos` in a member, `buffer[pos++] = c` therefore costs more than it looks: the compiler can't prove that the store of `c` didn't overwrite `pos` itself, so it writes -`pos` to memory before every character and loads it again after. A local variable whose address never escapes can't -be reached by any store, and stays in a register: +`pos` to memory before every character and loads it again after. A local variable whose address never escapes can't be +reached by any store, and stays in a register: ```cpp std::size_t pos = write_buffer_pos; // a local: no char store can reach it @@ -814,15 +838,17 @@ std::size_t pos = write_buffer_pos; // a local: no char store can reach it write_buffer_pos = pos; // published once at the end ``` -Keeping the write position in a local, so that the compiler can keep it in a register.
+Keeping the write position in a local, so that the compiler can keep it in a +register.
-On a 4 MiB binary value, [`dump()`](../../api/basic_json/dump.md) went from 18.0 ms to 7.4 ms with this change alone. `json_document`'s writer and -the binary writers keep their cursors in locals for the same reason, and publish them before any call that could see -them. +On a 4 MiB binary value, [`dump()`](../../api/basic_json/dump.md) went from 18.0 ms to 7.4 ms with this change alone. +`json_document`'s writer and the binary writers keep their cursors in locals for the same reason, and publish them +before any call that could see them. ### Loop shape beats branch count -A change that removed one branch per whitespace byte made scanning long runs of indentation 1.75–3.2× *slower* with Clang and GCC. It had rewritten `do { get(); } while (is_whitespace());` into +A change that removed one branch per whitespace byte made scanning long runs of indentation 1.75–3.2× *slower* with +Clang and GCC. It had rewritten `do { get(); } while (is_whitespace());` into `get(); while (is_whitespace()) { get_ignoring_pending_unget(); }`, and the assembly showed why it lost: neither compiler kept the input's read and end pointers in registers any more, and both reloaded them from memory on every character. Restoring the do-while shape, `get(); if (is_whitespace()) { do { ... } while (is_whitespace()); }`, fixed @@ -830,16 +856,15 @@ it. We don't know why the while loop lost the registers, only that the do-while ### Inlining that disappears -Inlining is a budget per function. Once the string writer had grown, GCC 13 and 16 -stopped inlining two tiny, unchanged helpers into the pretty-printing loop, and indented output got up to 13% slower. -They are now forced inline. A change in one place silently made an unrelated loop slower; only a benchmark of indented -output noticed. +Inlining is a budget per function. Once the string writer had grown, GCC 13 and 16 stopped inlining two tiny, unchanged +helpers into the pretty-printing loop, and indented output got up to 13% slower. They are now forced inline. A change in +one place silently made an unrelated loop slower; only a benchmark of indented output noticed. ### Branches the compiler chose -[`json_document`](../../api/json_document.md) writes most floats of `canada.json` by deciding, from where the -`double` lies relative to its source text, which of two or three candidate digit strings to write. The conditions of -those selections depend on the digits, so they are as good as random: +[`json_document`](../../api/json_document.md) writes most floats of `canada.json` by deciding, from where the `double` +lies relative to its source text, which of two or three candidate digit strings to write. The conditions of those +selections depend on the digits, so they are as good as random: ```cpp const bool up = from > 37 * one; @@ -848,12 +873,14 @@ const std::uint64_t a = w - r10 + (up ? 10u : 0u); return shorter ? a : b; ``` -Selecting a candidate digit string: GCC 13 compiled the conditional operators as jumps.
+Selecting a candidate digit string: GCC 13 compiled the conditional operators as +jumps.
-Clang compiled the `?:` as conditional moves; GCC 13 compiled them as jumps. With nothing to tell it that the -conditions are unpredictable, a branch that skips some work looks cheaper than computing both sides. [Callgrind](https://valgrind.org/docs/manual/cl-manual.html)'s branch -simulation counted 220,000 mispredictions per dump of `canada.json` with GCC, against 97,000 with Clang. -Written as masks, which have no branch form to reconstruct, the selections stay branch-free with GCC and Clang: +Clang compiled the `?:` as conditional moves; GCC 13 compiled them as jumps. With nothing to tell it that the conditions +are unpredictable, a branch that skips some work looks cheaper than computing both sides. +[Callgrind](https://valgrind.org/docs/manual/cl-manual.html)'s branch simulation counted 220,000 mispredictions per dump +of `canada.json` with GCC, against 97,000 with Clang. Written as masks, which have no branch form to reconstruct, the +selections stay branch-free with GCC and Clang: ```cpp // all ones or zero @@ -870,29 +897,30 @@ return select_if(shorter, a, b);The same selections written as masks, which stay branch-free with GCC and Clang.
-With GCC on x86-64, the mispredictions went down to 98,000, and the dump of `canada.json` got about 15% faster with -5% *more* instructions. With Clang, the time stayed the same. We found this one while writing this post. +With GCC on x86-64, the mispredictions went down to 98,000, and the dump of `canada.json` got about 15% faster with 5% +*more* instructions. With Clang, the time stayed the same. We found this one while writing this post. ### Code size as a cost -[`getHow the 3.12.0 serializer wrote an object's opening brace and the start of a key: three virtual calls.
+How the 3.12.0 serializer wrote an object's opening brace and the start of a key: +three virtual calls.
The writing side changed in five steps, one per subsection below: @@ -941,21 +970,21 @@ the returned string, and none if it fits into the string's small buffer. ### A concrete sink for the binary writers -The overloads of [`to_cbor()`](../../api/basic_json/to_cbor.md) and friends that return a `std::vector` use a writer that is a -template over the sink type, with no virtual call. Byte order is now swapped with `__builtin_bswap*` (or `_byteswap_*` with MSVC) instead of `std::reverse` -on a byte array: compilers can recognize the reversal as a byte swap in a small function, but in the CBOR number path, -Clang emitted no `bswap` at all. Writing into a vector also allows [`to_bson()`](../../api/basic_json/to_bson.md) to -make one pass instead of two: BSON puts the size of every document in front of it, so the writer used to walk the -value once for the sizes and once to write. It now writes a placeholder and fills in the size when the document is -complete, 12–20% faster on the M1. +The overloads of [`to_cbor()`](../../api/basic_json/to_cbor.md) and friends that return a `std::vector` use a writer +that is a template over the sink type, with no virtual call. Byte order is now swapped with `__builtin_bswap*` (or +`_byteswap_*` with MSVC) instead of `std::reverse` on a byte array: compilers can recognize the reversal as a byte swap +in a small function, but in the CBOR number path, Clang emitted no `bswap` at all. Writing into a vector also allows +[`to_bson()`](../../api/basic_json/to_bson.md) to make one pass instead of two: BSON puts the size of every document in +front of it, so the writer used to walk the value once for the sizes and once to write. It now writes a placeholder and +fills in the size when the document is complete, 12–20% faster on the M1. ### A cursor instead of `vector::insert()` After the previous step, writing an array of integers as CBOR was still no faster than in 3.12.0, and slower with GCC. The profile said why: each number's one to eight bytes were appended with `vector::insert()`, which both standard -libraries implement as an out-of-line insert-anywhere routine; it took 30% of the time, more than encoding the -numbers. The sink now keeps a cursor and a limit pointer, reserves space for a whole run of numbers at once, and writes -through the cursor: +libraries implement as an out-of-line insert-anywhere routine; it took 30% of the time, more than encoding the numbers. +The sink now keeps a cursor and a limit pointer, reserves space for a whole run of numbers at once, and writes through +the cursor: ```cpp CharType* const start = oa.reserve(max_run * integer_encoding_space); // one check per run @@ -967,15 +996,16 @@ for (n = 0; n < max_run && it != end && it->is_number_integer(); ++n, ++it) oa.commit(out - start); ``` -Encoding a run of integers through a cursor, with one capacity check for the whole run.
+Encoding a run of integers through a cursor, with one capacity check for the whole +run.
### Integers without branches The last step also took the branches out of encoding an integer. After the bounds checks, mispredicted branches -accounted for about 40% of the remaining time: the type, the sign, and the 1-, 2-, 4-, or 8-byte size class of each integer in a stream are -hard to predict. The encoder reads signed and unsigned integers through one masked path, looks the size class up from -the number of leading zero bits, and stores the head as one byte plus one 8-byte word, of which the caller keeps only -as many bytes as the class needs: +accounted for about 40% of the remaining time: the type, the sign, and the 1-, 2-, 4-, or 8-byte size class of each +integer in a stream are hard to predict. The encoder reads signed and unsigned integers through one masked path, looks +the size class up from the number of leading zero bits, and stores the head as one byte plus one 8-byte word, of which +the caller keeps only as many bytes as the class needs: ```cpp // 1, 2, 3 or 4 if x fits 1, 2, 4 or 8 bytes, without a branch @@ -998,58 +1028,60 @@ std::size_t encode_first_byte_and_tail(CharType* out, std::uint8_t first_byte, } ``` -Encoding the head of a CBOR integer without a branch: the size class from a table, the tail as one 8-byte store.
+Encoding the head of a CBOR integer without a branch: the size class from a table, +the tail as one 8-byte store.
-The table is a table because Clang vectorized the equivalent comparisons into slower code, and the unconditional -8-byte store is why `reserve()` asks for 9 bytes per integer. Writing an array of small signed integers as CBOR, -which had not gotten faster before, is now 4.2× as fast as with 3.12.0 with Clang on an M1 Max, and was 2.8× as fast -with GCC 13 on x86-64 when the change was made. One more -lesson from these changes: the loop over an array is a separate function that is never inlined. Inlined into the -function that writes any value, its register needs made every call of that function save more registers, once per -nested value, which cost `citm_catalog.json` 13%. +The table is a table because Clang vectorized the equivalent comparisons into slower code, and the unconditional 8-byte +store is why `reserve()` asks for 9 bytes per integer. Writing an array of small signed integers as CBOR, which had not +gotten faster before, is now 4.2× as fast as with 3.12.0 with Clang on an M1 Max, and was 2.8× as fast with GCC 13 on +x86-64 when the change was made. One more lesson from these changes: the loop over an array is a separate function that +is never inlined. Inlined into the function that writes any value, its register needs made every call of that function +save more registers, once per nested value, which cost `citm_catalog.json` 13%. ## 7 Fix the algorithm first { #theme-7 } None of the micro-optimizations above matter much next to an algorithm that is quadratic. Seven operations were quadratic or worse in 3.12.0. They are fixes of cases most programs never hit, not tuning, so they are not part of the -3.0× above (except `flatten()`, which the suite happens to measure); [Appendix D](#d-algorithmic-fixes) has the -numbers: +3.0× above (except `flatten()`, which the suite happens to measure); [Appendix D](#d-algorithmic-fixes) has the numbers:Operations that were quadratic or worse in 3.12.0, on a logarithmic scale; each arrow leads from the time with 3.12.0 to the time with 3.13.0. Less is better.
+Operations that were quadratic or worse in 3.12.0, on a logarithmic scale; each arrow +leads from the time with 3.12.0 to the time with 3.13.0. Less is better.
### Comparing in one pass -The most instructive one is [`operator<`](../../api/basic_json/operator_lt.md). Before C++20, `json`'s `operator<` on two arrays or -objects called the `operator<` of `std::vector` or `std::map`, which is `std::lexicographical_compare`. For every pair of elements it asks `a < b` and, -if that is false, `b < a`. When the elements are themselves containers, both questions recurse into the whole -subtree, and when they are equal, both have to go all the way down. So each level of nesting doubles the work: two -equal values nested 20 levels deep took 5.7 ms, 30 levels 3.8 seconds, and 40 levels would take about an hour. That -matters to anyone who keeps `json` values in a `std::set`, uses them as `std::map` keys, or sorts them. Since C++20, -`operator<` is derived from [`operator<=>`](../../api/basic_json/operator_spaceship.md), which asks once and was never affected. +The most instructive one is [`operator<`](../../api/basic_json/operator_lt.md). Before C++20, `json`'s `operator<` on +two arrays or objects called the `operator<` of `std::vector` or `std::map`, which is `std::lexicographical_compare`. +For every pair of elements it asks `a < b` and, if that is false, `b < a`. When the elements are themselves containers, +both questions recurse into the whole subtree, and when they are equal, both have to go all the way down. So each level +of nesting doubles the work: two equal values nested 20 levels deep took 5.7 ms, 30 levels 3.8 seconds, and 40 levels +would take about an hour. That matters to anyone who keeps `json` values in a `std::set`, uses them as `std::map` keys, +or sorts them. Since C++20, `operator<` is derived from [`operator<=>`](../../api/basic_json/operator_spaceship.md), +which asks once and was never affected. -3.13.0 compares in one pass over an explicit stack of iterator pairs and gets "less", "equal", "greater" or -"unordered" for each pair at once, so the ordering question is asked once per element. We found two subtleties by -comparing 68,121 results with the old code, not by reading it. `std::lexicographical_compare` steps over a pair it -cannot order (a NaN) and goes on, while the three-way version stops there, so the pass has a flag for which behavior -the caller needs. And object keys must be compared with `operator==` when testing equality, not with the object's comparator: -for `ordered_json`, that comparator is `std::equal_to`, and using it as "less" reports every equal key as smaller. The -single pass has a price: ordering flat arrays of numbers got 27% slower, while flat objects got 34% faster, in the pull request's measurement. +3.13.0 compares in one pass over an explicit stack of iterator pairs and gets "less", "equal", "greater" or "unordered" +for each pair at once, so the ordering question is asked once per element. We found two subtleties by comparing 68,121 +results with the old code, not by reading it. `std::lexicographical_compare` steps over a pair it cannot order (a NaN) +and goes on, while the three-way version stops there, so the pass has a flag for which behavior the caller needs. And +object keys must be compared with `operator==` when testing equality, not with the object's comparator: for +`ordered_json`, that comparator is `std::equal_to`, and using it as "less" reports every equal key as smaller. The +single pass has a price: ordering flat arrays of numbers got 27% slower, while flat objects got 34% faster, in the pull +request's measurement. ### Bounded recursion -The other side of this theme is depth. A value nested 100,000 levels deep used to crash `dump()`, the copy -constructor, `std::hash`, [`flatten()`](../../api/basic_json/flatten.md) and the BSON and UBJSON writers with a stack overflow, and in -optimized builds `operator==`, [`diff()`](../../api/basic_json/diff.md) and the other binary writers at 300,000 levels; -servers that copy JSON from clients hit that. Other libraries limit the depth of what they parse ([Boost.JSON](https://github.com/boostorg/json) at 32, -[serde_json](https://github.com/serde-rs/json) at 128, [simdjson](https://github.com/simdjson/simdjson) at 1024) or -parse and write iteratively ([yyjson](https://github.com/ibireme/yyjson)), and -[RapidJSON](https://github.com/Tencent/rapidjson) parses recursively unless asked otherwise. We tried making every walk -iterative, but that made `dump()`, copying and merging up to 20% slower on ordinary documents. So 3.13.0 bounds the -recursion instead: +The other side of this theme is depth. A value nested 100,000 levels deep used to crash `dump()`, the copy constructor, +`std::hash`, [`flatten()`](../../api/basic_json/flatten.md) and the BSON and UBJSON writers with a stack overflow, and +in optimized builds `operator==`, [`diff()`](../../api/basic_json/diff.md) and the other binary writers at 300,000 +levels; servers that copy JSON from clients hit that. Other libraries limit the depth of what they parse +([Boost.JSON](https://github.com/boostorg/json) at 32, [serde_json](https://github.com/serde-rs/json) at 128, +[simdjson](https://github.com/simdjson/simdjson) at 1024) or parse and write iteratively +([yyjson](https://github.com/ibireme/yyjson)), and [RapidJSON](https://github.com/Tencent/rapidjson) parses recursively +unless asked otherwise. We tried making every walk iterative, but that made `dump()`, copying and merging up to 20% +slower on ordinary documents. So 3.13.0 bounds the recursion instead: ```cpp constexpr std::size_t recursion_depth_limit() noexcept { return 128; } @@ -1075,22 +1107,22 @@ small object. ### Destroying without a stack -The destructor's walk for deep values uses pointer reversal, the idea behind the -[Schorr-Waite graph marking algorithm](https://doi.org/10.1145/363534.363554): while it empties a container, the slot of that container's last child holds the -link back to the container's parent, so the way back needs no memory ([Appendix I](#i-details-of-the-themes) shows the -loop). The walk allocates nothing and calls nothing that can throw, which fixed two older bugs: the previous +The destructor's walk for deep values uses pointer reversal, the idea behind the [Schorr-Waite graph marking +algorithm](https://doi.org/10.1145/363534.363554): while it empties a container, the slot of that container's last child +holds the link back to the container's parent, so the way back needs no memory ([Appendix I](#i-details-of-the-themes) +shows the loop). The walk allocates nothing and calls nothing that can throw, which fixed two older bugs: the previous destructor flattened children into a `std::vector`, which ignored the allocator type and could let `std::bad_alloc` escape from a `noexcept` destructor. A test checks with a counting allocator that destroying any value allocates -nothing. Against 3.12.0, destroying a parsed document is -1.24–1.55× as fast in our suite, and destroying an array of one million small objects 1.39×. +nothing. Against 3.12.0, destroying a parsed document is 1.24–1.55× as fast in our suite, and destroying an array of one +million small objects 1.39×. ## 8 Build a different data structure { #theme-8 } -After all of the above, [`json::parse()`](../../api/basic_json/parse.md) is still bound by what it builds: one heap allocation per string, array and -object, and a `std::map` node per member, all of which have to be freed again. Many programs only parse a document, -read some fields and throw it away, or change a few values and write it back. For them, 3.13.0 adds -[`json_document`](../../features/json_view.md) and `json_view` in a new header, `What `json::parse()` builds: a tree in which every object, array, and string is a separate heap allocation (dashed). The float is converted to the nearest `double` while parsing, so the original text `19.90` is gone: the stored value is 19.899999999999998578…, which `dump()` writes as `19.9`, the shortest text that reads back as the same `double`.
+What `json::parse()` builds: a tree in which every object, array, and string is a +separate heap allocation (dashed). The float is converted to the nearest `double` while parsing, so the original text +`19.90` is gone: the stored value is 19.899999999999998578…, which `dump()` writes as `19.9`, the shortest text that +reads back as the same `double`.
What `json_document::parse()` builds from the same text: one flat index of nine 16-byte nodes in document order, which point back into the source text, plus a buffer for the one string with an escape. A `json_view` is two pointers, here to the array.
+What `json_document::parse()` builds from the same text: one flat index of nine 16-byte +nodes in document order, which point back into the source text, plus a buffer for the one string with an escape. A +`json_view` is two pointers, here to the array.
### One flat index The design is not ours either. [simdjson's tape](https://github.com/simdjson/simdjson/blob/master/doc/tape.md) and -[yyjson](https://github.com/ibireme/yyjson)'s immutable document are both flat arrays of fixed-size -entries in document order instead of trees of separate allocations, and yyjson's entries are 16 bytes, too. Ours: +[yyjson](https://github.com/ibireme/yyjson)'s immutable document are both flat arrays of fixed-size entries in document +order instead of trees of separate allocations, and yyjson's entries are 16 bytes, too. Ours: ```cpp struct node @@ -1132,56 +1169,58 @@ static_assert(sizeof(node) == 16, "node must stay 16 bytes");A node of `json_document`'s index: 16 bytes for every value.
- **Skipping is an addition.** A container stores the size of its subtree, so the value after it is - `n + (is_container(*n) ? n->next : 1)`, and the members of an object are a key node followed by the value's - subtree. There are no pointers in the parsed index (edits add links, below); the parser fills in `next` when the container closes. `is_container()` - is a single subtraction and comparison, `kind - 1u <= 1u`, which is why objects and arrays are numbered 1 and 2. + `n + (is_container(*n) ? n->next : 1)`, and the members of an object are a key node followed by the value's subtree. + There are no pointers in the parsed index (edits add links, below); the parser fills in `next` when the container + closes. `is_container()` is a single subtraction and comparison, `kind - 1u <= 1u`, which is why objects and arrays + are numbered 1 and 2. - **Strings stay in the input.** Only strings with escapes are decoded, into an arena. A document makes a handful of allocations, however many values it has. The price is that the text must outlive the document, unless you let the document own a copy ([`parse_copy()`](../../api/basic_json_document/parse_copy.md), or an rvalue `std::string`). Offsets are 32 bits, which limits a document to 4 GiB. - **Integers are converted while their digits are in the cache,** into the last 8 bytes of their node. - **Floats stay text until you read them.** The node records the number of digits before and after the point, and - reading converts with the same Clinger/Eisel-Lemire code as `json::parse`. We tried converting them during the - parse. Although parsing `canada.json` and reading every number got 4–7% faster, parsing alone got 36–90% slower, - so we dropped it. A default `dump()` writes the shortest form of a float, as `json` does; [`number_format::source`](../../api/basic_json_view/number_format.md) copies the source - text of every number instead. -- **SIMD and tables** as in theme 2, and `\u` escapes decoded with a table, as yyjson does. -- **A hash index for big objects,** an idea from [Boost.JSON](https://github.com/boostorg/json). An object with 128 or more members gets an open-addressing - table after parsing; looking up every key of a 10,000-member object went from 59.8 ms to 0.16 ms. The hash is not - seeded, so crafted keys could make building the table quadratic. Probing is therefore capped at 64 slots: a key that - would need more drops the table, and that one object falls back to a linear search. Parsing can't become quadratic, - and a lookup is never slower than the linear search of a small object. + reading converts with the same Clinger/Eisel-Lemire code as `json::parse`. We tried converting them during the parse. + Although parsing `canada.json` and reading every number got 4–7% faster, parsing alone got 36–90% slower, so we + dropped it. A default `dump()` writes the shortest form of a float, as `json` does; + [`number_format::source`](../../api/basic_json_view/number_format.md) copies the source text of every number instead. +- **SIMD and tables** as in theme 2, and `\u` escapes decoded + with a table, as yyjson does. +- **A hash index for big objects,** an idea from [Boost.JSON](https://github.com/boostorg/json). An object with 128 or + more members gets an open-addressing table after parsing; looking up every key of a 10,000-member object went from + 59.8 ms to 0.16 ms. The hash is not seeded, so crafted keys could make building the table quadratic. Probing is + therefore capped at 64 slots: a key that would need more drops the table, and that one object falls back to a linear + search. Parsing can't become quadratic, and a lookup is never slower than the linear search of a small object. - **Validating writes no index.** [`json_document::accept()`](../../api/basic_json_document/accept.md) runs a second - instantiation of the same builder with a template parameter that turns every store off, so the checks are the - same code, but no node is written and no escaped string is decoded. On x86-64 that made it 17–31% faster on - `twitter.json`, `citm_catalog.json` and `canada.json`, and the 56 MB `jeopardy.json` no longer needs 52 MB of fresh memory just to be validated. -- **First duplicate wins.** Lookups return the first of duplicate keys, as yyjson and simdjson do, because returning - the last one meant scanning every object to its end and made lookups in small objects 1.6–3.4× slower. - `json::parse()` keeps the last one, so for a text with duplicate keys, the two can disagree; the documentation - says so. + instantiation of the same builder with a template parameter that turns every store off, so the checks are the same + code, but no node is written and no escaped string is decoded. On x86-64 that made it 17–31% faster on `twitter.json`, + `citm_catalog.json` and `canada.json`, and the 56 MB `jeopardy.json` no longer needs 52 MB of fresh memory just to be + validated. +- **First duplicate wins.** Lookups return the first of duplicate keys, as yyjson and simdjson do, because returning the + last one meant scanning every object to its end and made lookups in small objects 1.6–3.4× slower. `json::parse()` + keeps the last one, so for a text with duplicate keys, the two can disagree; the documentation says so. ### It can be edited, too -A read-only index is the easy case, and a fair objection is that `json_document` is only -fast because it can't change. [`json_editable_document`](../../features/json_view.md#editing-a-document) has `set()`, -`insert()`, `erase()` and `push_back()`. An edit never writes into the source text and never moves the parsed index: +A read-only index is the easy case, and a fair objection is that `json_document` is only fast because it can't change. +[`json_editable_document`](../../features/json_view.md#editing-a-document) has `set()`, `insert()`, `erase()` and +`push_back()`. An edit never writes into the source text and never moves the parsed index: - a new scalar is written over its own node, with new string and number text in an append-only arena; - new arrays and objects are allocated in chunks of nodes that never move; - a container whose elements change is marked as moved and gets a separate list of its entries: links to the values, which stay where they are. The list doubles when it grows, so `push_back()` is amortized constant. -Views stay valid across edits, because values never move. Reading an edited container costs one flag test per -container and one test per element for a link. The link case is out of line, so that it compiles to a predicted -branch rather than a conditional select that would delay the following loads. Whereas a read-only document carries -none of this, an editable one that hasn't been edited reads within 2% of it on most rows and within 8% on the worst. And because untouched values keep -their source text, [`dump()`](../../api/basic_json_view/dump.md) with -`number_format::source` writes back a patched configuration file with its member order -and the spelling of every number (`19.90`) intact, which `json` (sorted keys) and `ordered_json` (`19.9`) can't. +Views stay valid across edits, because values never move. Reading an edited container costs one flag test per container +and one test per element for a link. The link case is out of line, so that it compiles to a predicted branch rather than +a conditional select that would delay the following loads. Whereas a read-only document carries none of this, an +editable one that hasn't been edited reads within 2% of it on most rows and within 8% on the worst. And because +untouched values keep their source text, [`dump()`](../../api/basic_json_view/dump.md) with `number_format::source` +writes back a patched configuration file with its member order and the spelling of every number (`19.90`) intact, which +`json` (sorted keys) and `ordered_json` (`19.9`) can't. -The limits are documented: `set()` of a new key searches the object's members, so building a large object key by key -is quadratic, as with `ordered_json`, and an object that was edited loses its hash index and is searched linearly. -[How it compares](#how-it-compares) times the editable document against the mutable trees of other libraries. +The limits are documented: `set()` of a new key searches the object's members, so building a large object key by key is +quadratic, as with `ordered_json`, and an object that was edited loses its hash index and is searched linearly. [How it +compares](#how-it-compares) times the editable document against the mutable trees of other libraries. ## How it compares @@ -1195,52 +1234,53 @@ They hold a parsed JSON text in four different ways: | **forward-only reading** | each value once, in document order: events (SAX) or a cursor | nothing is built; you can't go back | nlohmann/json [`sax_parse`](../../api/basic_json/sax_parse.md), RapidJSON `Reader`, Boost.JSON `basic_parser`, simdjson On-Demand, Glaze `lazy_json`; serde visitors in Rust | | **into your own types** | the fields you declared, nothing else | a struct per document shape; other fields are skipped | Glaze reading into structs, serde with `derive`, nlohmann/json `from_json()` from `json` or from a view | -The kinds of representation the libraries in this comparison offer. Most libraries offer more than one.
+The kinds of representation the libraries in this comparison offer. Most libraries +offer more than one.
-So a comparison of parse times across the rows of this table compares different amounts of work. Instead, we -compare **use cases**: tasks a program actually has, each of which every library may solve in whatever way it does -best. In nlohmann/json, `json` is the mutable tree and `json_document` the flat document, so we show the library -twice: its best way *with* `json_document` and its best way *without* it. +So a comparison of parse times across the rows of this table compares different amounts of work. Instead, we compare +**use cases**: tasks a program actually has, each of which every library may solve in whatever way it does best. In +nlohmann/json, `json` is the mutable tree and `json_document` the flat document, so we show the library twice: its best +way *with* `json_document` and its best way *without* it. ### Six use cases -Each starts from the file's text in a `std::string` (or a byte slice in Rust) and ends with everything the library -built freed again: +Each starts from the file's text in a `std::string` (or a byte slice in Rust) and ends with everything the library built +freed again: -- **validate:** is this text valid JSON? Nothing is kept, so this measures the parser alone, as in a server that - checks a request before it passes it on. +- **validate:** is this text valid JSON? Nothing is kept, so this measures the parser alone, as in a server that checks + a request before it passes it on. - **sum:** visit every value in document order, convert every number, and add them up. This measures walking a whole document and converting all of its numbers. -- **select:** read a few fields of every record, such as the id, the user name, and the retweet count of every tweet, - as a program does that needs only part of a response. A library that reads lazily can skip the rest. +- **select:** read a few fields of every record, such as the id, the user name, and the retweet count of every tweet, as + a program does that needs only part of a response. A library that reads lazily can skip the rest. - **edit:** change a value, remove a member, and add a member in every record, then write the result as compact JSON: the round trip of a program that patches a message or a configuration. - **minify:** read the text and write it as compact JSON, without whitespace. This measures reading and writing together, without changes in between. - **write:** write a document that was parsed beforehand as compact JSON; only the writing is timed. -Each use case runs on each of the four test files, and each library may solve it in every way it offers: in place, -with a SAX handler, into structs, with a parser that is reused (measured, but not chosen). Before anything is timed, -every way's result is checked: the same values, the same string bytes, the same sum of all numbers (to the last bit where they are -added in the same order), and an output that reads back as the expected document. The -[README of the programs](https://github.com/nlohmann/json/tree/develop/tests/benchmarks/json_view/others) lists every -way, the fields and edits per file, and the checks. +Each use case runs on each of the four test files, and each library may solve it in every way it offers: in place, with +a SAX handler, into structs, with a parser that is reused (measured, but not chosen). Before anything is timed, every +way's result is checked: the same values, the same string bytes, the same sum of all numbers (to the last bit where they +are added in the same order), and an output that reads back as the expected document. The [README of the +programs](https://github.com/nlohmann/json/tree/develop/tests/benchmarks/json_view/others) lists every way, the fields +and edits per file, and the checks. ### Where the input comes from -Starting from a string in memory is how most JSON benchmarks work: it measures the library, not the disk -or the operating system. It is not neutral in every detail, though: +Starting from a string in memory is how most JSON benchmarks work: it measures the library, not the disk or the +operating system. It is not neutral in every detail, though: -- The string has 64 bytes of spare capacity, so simdjson, which reads up to 64 bytes past the end, can use it without - a copy. A program would load the file into such a buffer in the first place. +- The string has 64 bytes of spare capacity, so simdjson, which reads up to 64 bytes past the end, can use it without a + copy. A program would load the file into such a buffer in the first place. - yyjson, RapidJSON, and simd-json can parse in place, which changes the buffer. So every call gets a fresh copy of the - string, and the copy counts toward their time: 11–14 µs for `twitter.json` on the Mac, and 30–46 µs on x86-64, - where fresh memory costs page faults. yyjson's normal read makes the same copy internally, which is why both ways - take about as long. A server that validates a request and then passes it on needs the copy. A program that reads a - file into a buffer it owns can skip it, so we also timed the in-place ways with the copy made before the timer - starts. On the Mac, yyjson then comes 2–4% closer and stays second, and on `twitter.json` it sums and selects 2–3% - faster than `json_document`. On x86-64, it ties `json_document` in *sum* with Clang and takes 1.06× and 1.19× its - time with GCC 16 and GCC 13. + string, and the copy counts toward their time: 11–14 µs for `twitter.json` on the Mac, and 30–46 µs on x86-64, where + fresh memory costs page faults. yyjson's normal read makes the same copy internally, which is why both ways take about + as long. A server that validates a request and then passes it on needs the copy. A program that reads a file into a + buffer it owns can skip it, so we also timed the in-place ways with the copy made before the timer starts. On the Mac, + yyjson then comes 2–4% closer and stays second, and on `twitter.json` it sums and selects 2–3% faster than + `json_document`. On x86-64, it ties `json_document` in *sum* with Clang and takes 1.06× and 1.19× its time with GCC 16 + and GCC 13. - `json_document` reads the string in place and keeps pointing into it. A document that owns its text ([`parse_copy()`](../../api/basic_json_document/parse_copy.md)) costs one copy more: 3–5% on the Mac. - Reading from files and streams is not part of the comparison. @@ -1255,141 +1295,156 @@ flowchart TB end ``` -What one timed call of a use case contains. For *write*, the document is parsed before the timer starts, so only the writing is timed. Every way's result is checked before anything is timed.
+What one timed call of a use case contains. For *write*, the document is parsed before +the timer starts, so only the writing is timed. Every way's result is checked before anything is timed.
### Checking less, on purpose -Some libraries can skip parts of the validation, and that is often why they are fast. A -lazy reader such as simdjson's On-Demand API or Glaze's `lazy_json` only looks at what you ask for; to step over the -rest, it matches brackets without looking inside, so reading a few fields of a large record costs little more than -finding them. A minifier only needs to find whitespace, not understand the values. Skipping the UTF-8 check is -RapidJSON's default and sonic-cpp's too. Each is a good choice for input that is known to be valid, for example text -your own program wrote a moment ago. To tell these ways apart, every way is also fed four broken inputs: the file cut -short by one byte, a stray comma, an invalid UTF-8 byte in a string, and garbage after the value. A way that rejects all four validates fully, -and each cell shows the fastest such way. A way that checks or does less appears in the right half -of the cell, with a letter that the caption explains. +Some libraries can skip parts of the validation, and that is often why they are fast. A lazy reader such as simdjson's +On-Demand API or Glaze's `lazy_json` only looks at what you ask for; to step over the rest, it matches brackets without +looking inside, so reading a few fields of a large record costs little more than finding them. A minifier only needs to +find whitespace, not understand the values. Skipping the UTF-8 check is RapidJSON's default and sonic-cpp's too. Each is +a good choice for input that is known to be valid, for example text your own program wrote a moment ago. To tell these +ways apart, every way is also fed four broken inputs: the file cut short by one byte, a stray comma, an invalid UTF-8 +byte in a string, and garbage after the value. A way that rejects all four validates fully, and each cell shows the +fastest such way. A way that checks or does less appears in the right half of the cell, with a letter that the caption +explains. -A word on who set the rules: we wrote every library's benchmark code, chose the files and the rules, and tuned -3.13.0 on the same machines and files. No maintainer of the other libraries has reviewed their code. If you know a -faster way for any of them, please open an issue, and we will rerun the comparison. To check ourselves, we also ran files and a conformance suite we never tuned on ([below](#files-and-checks-we-didnt-choose)). +A word on who set the rules: we wrote every library's benchmark code, chose the files and the rules, and tuned 3.13.0 on +the same machines and files. No maintainer of the other libraries has reviewed their code. If you know a faster way for +any of them, please open an issue, and we will rerun the comparison. To check ourselves, we also ran files and a +conformance suite we never tuned on ([below](#files-and-checks-we-didnt-choose)).Time per library and use case on an Apple M1 Max: each library's fastest fully validating way, as the geometric mean over `twitter.json`, `citm_catalog.json`, `canada.json` and `jeopardy.json`, so that every file counts equally, divided by the fastest library's mean, which therefore shows 1.00. Less is better. Where a way that checks or does less is at least 3% faster, it is shown in the right half of the cell: "U" trusts UTF-8, "P" reads lazily, only removes whitespace or, in one Glaze cell on x86-64, doesn't check what follows the value, and "N" copies number text instead of formatting it. "n/a" means the library has no API for the use case. Hover over or tap a cell for the times per file and the way; the tables are in [Appendix E](#e-comparison-with-other-libraries). sonic-cpp's README lists only x86-64 Linux; we show it on the Mac anyway.
+Time per library and use case on an Apple M1 Max: each library's fastest fully validating +way, as the geometric mean over `twitter.json`, `citm_catalog.json`, `canada.json` and `jeopardy.json`, so that every +file counts equally, divided by the fastest library's mean, which therefore shows 1.00. Less is better. Where a way that +checks or does less is at least 3% faster, it is shown in the right half of the cell: "U" trusts UTF-8, "P" reads +lazily, only removes whitespace or, in one Glaze cell on x86-64, doesn't check what follows the value, and "N" copies +number text instead of formatting it. "n/a" means the library has no API for the use case. Hover over or tap a cell for +the times per file and the way; the tables are in [Appendix E](#e-comparison-with-other-libraries). sonic-cpp's README +lists only x86-64 Linux; we show it on the Mac anyway.
### Results on the Mac On the Apple M1 Max, among the ways that reject all four broken inputs, `json_document` has the lowest mean on the four -files in all six use cases, and yyjson is second in every column. That includes editing, where the editable document is faster than yyjson's mutable document: -the speed doesn't depend on being read-only. +files in all six use cases, and yyjson is second in every column. That includes editing, where the editable document is +faster than yyjson's mutable document: the speed doesn't depend on being read-only. Some caveats belong next to that: -- **The lead is not the same on every file.** On `twitter.json`, yyjson is within 3–7% in *validate*, *sum*, - and *select*. Most of the lead comes from `citm_catalog.json`, the floats of `canada.json` (next point) and the - size of `jeopardy.json`. -- **Floats are converted only when read.** That helps `json_document` in *validate* and *select* on `canada.json`, - where most others convert 111,080 floats nobody looks at. Libraries that can skip the conversion use that mode - there, but only Boost.JSON gains much: it validates `canada.json` in 47% of the time. *sum* reads every number, so it - is the fair comparison for floats; there, `json_document` is still slightly ahead of yyjson. -- **Checking less pays in *select*.** If the input needn't be checked fully, simdjson's On-Demand API and Glaze - reading into structs select in 0.75× and 0.82× `json_document`'s time (Glaze is faster only on `citm_catalog.json` and - `canada.json`), and in 0.54–0.62× on x86-64: the right - halves of the *select* column. -- **Indentation.** Two of the four files are indented, which theme 2 helps. On minified copies of - `twitter.json` and `citm_catalog.json`, yyjson comes closer in *sum* (1.11 → 1.04) but not in - *validate* (1.28 → 1.27), and `json_document` stays first. -- **Number text can be copied.** With [`number_format::source`](../../api/basic_json_view/number_format.md), `json_document` writes each number by copying its text - instead of formatting it, and yyjson and RapidJSON can read numbers as text and write them back unchanged. The - output keeps the source's spelling of each number, which is all a minifier needs. It makes writing `canada.json` - with `json_document` 4.0× as fast and minifying it 1.9× as fast; the "N" halves of *edit*, *minify*, and - *write* show it. -- **Small documents.** On a 2.5 KB message, the fastest libraries take about a microsecond on the Mac and 1.2–2 - on x86-64. yyjson validates and sums it as fast as `json_document` on the Mac, and with GCC 16 on x86-64, - simdjson and yyjson sum it slightly faster. +- **The lead is not the same on every file.** On `twitter.json`, yyjson is within 3–7% in *validate*, *sum*, and + *select*. Most of the lead comes from `citm_catalog.json`, the floats of `canada.json` (next point) and the size of + `jeopardy.json`. +- **Floats are converted only when read.** That helps `json_document` in *validate* and *select* on `canada.json`, where + most others convert 111,080 floats nobody looks at. Libraries that can skip the conversion use that mode there, but + only Boost.JSON gains much: it validates `canada.json` in 47% of the time. *sum* reads every number, so it is the fair + comparison for floats; there, `json_document` is still slightly ahead of yyjson. +- **Checking less pays in *select*.** If the input needn't be checked fully, simdjson's On-Demand API and Glaze reading + into structs select in 0.75× and 0.82× `json_document`'s time (Glaze is faster only on `citm_catalog.json` and + `canada.json`), and in 0.54–0.62× on x86-64: the right halves of the *select* column. +- **Indentation.** Two of the four files are indented, which theme + 2 helps. On minified copies of `twitter.json` and + `citm_catalog.json`, yyjson comes closer in *sum* (1.11 → 1.04) but not in *validate* (1.28 → 1.27), and + `json_document` stays first. +- **Number text can be copied.** With [`number_format::source`](../../api/basic_json_view/number_format.md), + `json_document` writes each number by copying its text instead of formatting it, and yyjson and RapidJSON can read + numbers as text and write them back unchanged. The output keeps the source's spelling of each number, which is all a + minifier needs. It makes writing `canada.json` with `json_document` 4.0× as fast and minifying it 1.9× as fast; the + "N" halves of *edit*, *minify*, and *write* show it. +- **Small documents.** On a 2.5 KB message, the fastest libraries take about a microsecond on the Mac and 1.2–2 on + x86-64. yyjson validates and sums it as fast as `json_document` on the Mac, and with GCC 16 on x86-64, simdjson and + yyjson sum it slightly faster. ### Reading into structs -A view converts into your own types with the same [`from_json()`](../../features/arbitrary_types.md) as `json`, including the -ones the [`NLOHMANN_DEFINE_TYPE_*`](../../api/macros/nlohmann_define_type_non_intrusive.md) macros generate. That is as fast as picking the fields by hand from the view; only -for the many small records of `jeopardy.json` does it take 23% longer. On the Mac, Glaze, serde_json, and -Boost.JSON's `parse_into`, reading into the same structs and checking everything, take 1.7×, 2.2×, and 3.1× as long -(geometric mean over the four files). On a `json` value, reading into the structs takes about as -long as `json::parse()` alone. +A view converts into your own types with the same [`from_json()`](../../features/arbitrary_types.md) as `json`, +including the ones the [`NLOHMANN_DEFINE_TYPE_*`](../../api/macros/nlohmann_define_type_non_intrusive.md) macros +generate. That is as fast as picking the fields by hand from the view; only for the many small records of +`jeopardy.json` does it take 23% longer. On the Mac, Glaze, serde_json, and Boost.JSON's `parse_into`, reading into the +same structs and checking everything, take 1.7×, 2.2×, and 3.1× as long (geometric mean over the four files). On a +`json` value, reading into the structs takes about as long as `json::parse()` alone. ### nlohmann/json without `json_document` -And nlohmann/json without `json_document`? `json` builds a tree of separate allocations, and it shows: for -*select*, *edit*, and *minify* it takes several times as long as `json_document`, like Boost.JSON and RapidJSON. Where -no tree is needed, it is closer: on the Mac, a SAX handler is on par with Boost.JSON's, and -[`json::accept()`](../../api/basic_json/accept.md) takes 6% longer than Boost.JSON without number conversion (on -x86-64, Boost.JSON validates in 0.65–0.69× and sums in 0.75–0.79× the time). `dump()` writes faster than Boost.JSON, -RapidJSON and serde_json, and about as fast as Glaze. +And nlohmann/json without `json_document`? `json` builds a tree of separate allocations, and it shows: for *select*, +*edit*, and *minify* it takes several times as long as `json_document`, like Boost.JSON and RapidJSON. Where no tree is +needed, it is closer: on the Mac, a SAX handler is on par with Boost.JSON's, and +[`json::accept()`](../../api/basic_json/accept.md) takes 6% longer than Boost.JSON without number conversion (on x86-64, +Boost.JSON validates in 0.65–0.69× and sums in 0.75–0.79× the time). `dump()` writes faster than Boost.JSON, RapidJSON +and serde_json, and about as fast as Glaze. ### Results on x86-64 -On x86-64, the same programs ran on a virtual server with Ubuntu 26.04 whose CPU presents itself as Haswell, -pinned to one core. We built them with `-march=haswell` and three compilers: the newest GCC, 16.2 (the figure below), -GCC 13.4, and Clang 23 (both in [Appendix E](#e-comparison-with-other-libraries)). The compiler matters less than one might think. On the Mac, GCC 16 -and Clang 23 put the same library first in every column as Apple clang, and on x86-64, the three compilers disagree -only about *sum*. +On x86-64, the same programs ran on a virtual server with Ubuntu 26.04 whose CPU presents itself as Haswell, pinned to +one core. We built them with `-march=haswell` and three compilers: the newest GCC, 16.2 (the figure below), GCC 13.4, +and Clang 23 (both in [Appendix E](#e-comparison-with-other-libraries)). The compiler matters less than one might think. +On the Mac, GCC 16 and Clang 23 put the same library first in every column as Apple clang, and on x86-64, the three +compilers disagree only about *sum*.The same comparison on the x86-64 server with GCC 16.2, with the same cells and halves as the figure above. Less is better; hover over or tap a cell for the times and the way. The figures for GCC 13 and Clang 23 are in [Appendix E](#e-comparison-with-other-libraries).
+The same comparison on the x86-64 server with GCC 16.2, with the same cells and halves as +the figure above. Less is better; hover over or tap a cell for the times and the way. The figures for GCC 13 and Clang +23 are in [Appendix E](#e-comparison-with-other-libraries).
`json_document` has the lowest mean in five of the six columns with GCC, where simdjson is ahead in *sum*, and in all -six with Clang. This machine is noisy, though: between two runs, a library's time relative to `json_document`'s moved -by up to 9–10% (95th percentile; 2.5% on the Mac). So Clang's 7% lead in *sum* over sonic-rs is no result, and -without `twitter.json`, sonic-rs is first. In *validate*, the next library takes 1.8–1.9× as long, or 1.6–1.7× if every -library may reuse its parser, because validating no longer builds an index (theme 8). Behind those means, it loses on single files: +six with Clang. This machine is noisy, though: between two runs, a library's time relative to `json_document`'s moved by +up to 9–10% (95th percentile; 2.5% on the Mac). So Clang's 7% lead in *sum* over sonic-rs is no result, and without +`twitter.json`, sonic-rs is first. In *validate*, the next library takes 1.8–1.9× as long, or 1.6–1.7× if every library +may reuse its parser, because validating no longer builds an index (theme +8). Behind those means, it loses on single files: - **One pass over every value, with GCC.** simdjson's On-Demand API sums all numbers of `twitter.json`, - `citm_catalog.json`, and `canada.json` in 0.83–0.99× `json_document`'s time with GCC 16 (0.86–0.91× with - GCC 13). With Clang, `json_document` is ahead there, except on `canada.json`, where yyjson, which converts the - floats during the parse, is faster than converting them on access. -- **Fresh large documents.** For the 56 MB `jeopardy.json`, a fresh document needs a fresh 52 MB index, and on Linux - the page faults of that much new memory are a large part of the time: copying the file into fresh memory alone - takes about 50% as long as `json_document`'s parse. Boost.JSON's SAX parser and sonic-rs, which build nothing, - sum it in 0.66–0.72× the time. Reusing the document cuts `json_document`'s time by 40–43%, to 51–53 ms, - less than sonic-rs's 60 ms. Validating needs no index: `json_document::accept()` takes 35 ms, and no - library validates `jeopardy.json` faster, even reused (the next is simdjson's reused parser at 47–49 ms). On the Mac, a - fresh document costs hardly more than a reused one. + `citm_catalog.json`, and `canada.json` in 0.83–0.99× `json_document`'s time with GCC 16 (0.86–0.91× with GCC 13). With + Clang, `json_document` is ahead there, except on `canada.json`, where yyjson, which converts the floats during the + parse, is faster than converting them on access. +- **Fresh large documents.** For the 56 MB `jeopardy.json`, a fresh document needs a fresh 52 MB index, and on Linux the + page faults of that much new memory are a large part of the time: copying the file into fresh memory alone takes about + 50% as long as `json_document`'s parse. Boost.JSON's SAX parser and sonic-rs, which build nothing, sum it in + 0.66–0.72× the time. Reusing the document cuts `json_document`'s time by 40–43%, to 51–53 ms, less than sonic-rs's 60 + ms. Validating needs no index: `json_document::accept()` takes 35 ms, and no library validates `jeopardy.json` faster, + even reused (the next is simdjson's reused parser at 47–49 ms). On the Mac, a fresh document costs hardly more than a + reused one. - **Writing floats.** yyjson writes `canada.json` in 0.87–0.97× the time (and Glaze, with GCC 13, in 0.96×), because - `json_document` first converts most floats from their text. Writing the source text instead - (`number_format::source`, the "N" half) avoids that. -- **simdjson's DOM.** It selects from `citm_catalog.json` 16% faster with GCC 16 and 1–4% faster with GCC 13 and - Clang 23, and from `twitter.json` 1–6% faster. Boost.JSON, Glaze, sonic-cpp, and yyjson are 1–3% ahead in a few - more cells, which is within the noise. + `json_document` first converts most floats from their text. Writing the source text instead (`number_format::source`, + the "N" half) avoids that. +- **simdjson's DOM.** It selects from `citm_catalog.json` 16% faster with GCC 16 and 1–4% faster with GCC 13 and Clang + 23, and from `twitter.json` 1–6% faster. Boost.JSON, Glaze, sonic-cpp, and yyjson are 1–3% ahead in a few more cells, + which is within the noise. The full tables, with each library's variants, are in [Appendix E](#e-comparison-with-other-libraries). ### Files and checks we didn't choose { #files-and-checks-we-didnt-choose } -The four files are the ones 3.13.0 was tuned on, so we also ran *validate*, *sum*, *minify*, and *write* on the 19 -other files of simdjson's [`jsonexamples`](https://github.com/simdjson/simdjson-data/tree/master/jsonexamples). By -geometric mean, `json_document` stays the fastest in *validate*, *minify*, and *write* on every compiler, and in *sum* -with Clang and on the Mac; with GCC, simdjson sums in 0.86–0.87× its time. File by file, for *validate*: +The four files are the ones 3.13.0 was tuned on, so we also ran *validate*, *sum*, *minify*, and *write* on the 19 other +files of simdjson's [`jsonexamples`](https://github.com/simdjson/simdjson-data/tree/master/jsonexamples). By geometric +mean, `json_document` stays the fastest in *validate*, *minify*, and *write* on every compiler, and in *sum* with Clang +and on the Mac; with GCC, simdjson sums in 0.86–0.87× its time. File by file, for *validate*:*validate* on the 19 files: `json_document`'s fastest fully validating way against the fastest other library's, per file; below 1.0× it is the fastest. Less is better. The right column names the next library on x86-64 (GCC 16.2).
+*validate* on the 19 files: `json_document`'s fastest fully validating way against the +fastest other library's, per file; below 1.0× it is the fastest. Less is better. The right column names the next library +on x86-64 (GCC 16.2).
`json_document` validates all 19 files fastest with GCC 16 and Clang 23, and 18 with GCC 13 and on the Mac: simdjson -validates `apache_builds` 8% faster on the Mac and `google_maps_api_response` 2% faster with GCC 13. Before validating without an index -(theme 8) and skipping fraction digits 16 bytes at a time (theme 2), Boost.JSON validated the number-heavy files up to 1.5× -as fast on x86-64. +validates `apache_builds` 8% faster on the Mac and `google_maps_api_response` 2% faster with GCC 13. Before validating +without an index (theme 8) and skipping fraction digits 16 bytes +at a time (theme 2), Boost.JSON validated the number-heavy files +up to 1.5× as fast on x86-64. As a check of the four probes, every *validate* way also ran the 318 files of [JSONTestSuite](https://github.com/nst/JSONTestSuite). [Appendix E](#e-comparison-with-other-libraries) lists the -exceptions, one of them ours. The suite also found that -Glaze's generic reader accepts garbage after the value by default, so the figures use its strict option. +exceptions, one of them ours. The suite also found that Glaze's generic reader accepts garbage after the value by +default, so the figures use its strict option. We did not measure memory use, a CPU with AVX-512, MSVC, writing from structs, or streaming input. @@ -1410,47 +1465,50 @@ The pull requests record the ideas that measured worse, which is as useful as th | `json_document`: note a large object inline in the parse loop | `canada.json` 37% slower | | `json_document`: vector-scan every string in `set()` | 14–16% slower on 16-byte strings | -Ideas that were measured and dropped. [Appendix H](#h-pull-requests-by-theme) links the pull requests that tried them.
+Ideas that were measured and dropped. [Appendix H](#h-pull-requests-by-theme) links +the pull requests that tried them.
## The price -None of this is free at compile time. `json.hpp` grew from 25,526 to 46,630 lines (`json_view.hpp` is a separate -header of 11,898 lines that you only pay for if you include it). To see what that means for a program, we compiled two -small ones with both versions: +None of this is free at compile time. `json.hpp` grew from 25,526 to 46,630 lines (`json_view.hpp` is a separate header +of 11,898 lines that you only pay for if you include it). To see what that means for a program, we compiled two small +ones with both versions: -- `count_numbers` reads `twitter.json` and adds up all of its numbers, like the *sum* use case in - [How it compares](#six-use-cases); -- `server_config` reads a configuration file into a struct with `to_json()` and `from_json()`, changes it, and writes - it back with `dump(2)`. +- `count_numbers` reads `twitter.json` and adds up all of its numbers, like the *sum* use case in [How it + compares](#six-use-cases); +- `server_config` reads a configuration file into a struct with `to_json()` and `from_json()`, changes it, and writes it + back with `dump(2)`. -Each is one translation unit. We timed it with the 3.12.0 and the 3.13.0 header, and then with 3.13.0 as a -precompiled header and as a C++20 module, in a debug build (`-O0`) and an optimized one (`-O2`): +Each is one translation unit. We timed it with the 3.12.0 and the 3.13.0 header, and then with 3.13.0 as a precompiled +header and as a C++20 module, in a debug build (`-O0`) and an optimized one (`-O2`):How much longer or shorter each program takes to compile than with 3.12.0, less is better: Apple clang 21 and GCC 16.2, CPU time of the compiler, median of 5 interleaved runs, C++20, Apple M1 Max. The one-off cost of building the precompiled header or the module is not included. Hover over a bar for the time in milliseconds; [Appendix G](#g-how-we-measured) has all times, the programs, and the commands.
+How much longer or shorter each program takes to compile than with 3.12.0, less is +better: Apple clang 21 and GCC 16.2, CPU time of the compiler, median of 5 interleaved runs, C++20, Apple M1 Max. The +one-off cost of building the precompiled header or the module is not included. Hover over a bar for the time in +milliseconds; [Appendix G](#g-how-we-measured) has all times, the programs, and the commands.
-3.13.0 takes 34–62% longer to compile these programs. A good part of that is reading the header, and a -precompiled header or a module does that once instead of in every translation unit: +3.13.0 takes 34–62% longer to compile these programs. A good part of that is reading the header, and a precompiled +header or a module does that once instead of in every translation unit: -- **In debug builds,** which are what you wait for while you edit, a precompiled 3.13.0 compiles faster than 3.12.0 - with Apple clang, and within 8–15% of it with GCC. +- **In debug builds,** which are what you wait for while you edit, a precompiled 3.13.0 compiles faster than 3.12.0 with + Apple clang, and within 8–15% of it with GCC. - **In optimized builds,** less of the increase goes away, because the optimizer still works through all the code a program instantiates. A precompiled header or a module wins back 41–91% of it. -- **The one-off cost** is 1.0 s (Apple clang) or 2.6 s (GCC) to build the precompiled header, and 1.6 s or 2.5 s - to build the module, once per set of compiler flags. A precompiled header pays for itself after two to four - translation units, a module after three to five. -- **Modules have caveats.** Apple clang needs `-fcxx-modules`, [module support](../../features/modules.md) still - depends heavily on the compiler version, and macros don't cross a module boundary: with `import nlohmann.json;`, - the `NLOHMANN_DEFINE_TYPE_*` macros aren't available, which is why `server_config` writes `to_json()` and - `from_json()` by hand. +- **The one-off cost** is 1.0 s (Apple clang) or 2.6 s (GCC) to build the precompiled header, and 1.6 s or 2.5 s to + build the module, once per set of compiler flags. A precompiled header pays for itself after two to four translation + units, a module after three to five. +- **Modules have caveats.** Apple clang needs `-fcxx-modules`, [module support](../../features/modules.md) still depends + heavily on the compiler version, and macros don't cross a module boundary: with `import nlohmann.json;`, the + `NLOHMANN_DEFINE_TYPE_*` macros aren't available, which is why `server_config` writes `to_json()` and `from_json()` by + hand. -The [compile-time page](../../integration/compile_times.md) measures these and the other options, such as -including `The test files. The first three are from Milo Yip's [nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark). The counts are of the parsed values; the share of whitespace, string, and non-ASCII bytes is of the file as distributed. Several changes target the indentation of `citm_catalog.json`: in the timeline benchmark, a minified copy of it parses 1.9× as fast as with 3.12.0, against 3.4× for the file as distributed.
+The test files. The first three are from Milo Yip's +[nativejson-benchmark](https://github.com/miloyip/nativejson-benchmark). The counts are of the parsed values; the share +of whitespace, string, and non-ASCII bytes is of the file as distributed. Several changes target the indentation of +`citm_catalog.json`: in the timeline benchmark, a minified copy of it parses 1.9× as fast as with 3.12.0, against 3.4× +for the file as distributed.
### B. 3.12.0 against 3.13.0 @@ -1553,7 +1614,10 @@ The rows of the first chart: | copying `citm_catalog.json` and destroying the copy | 1.70 ms | 1.51 ms | 1.13× | 1.12× | | `==` of `citm_catalog.json` and a copy | 0.280 ms | 0.263 ms | 1.06× | 0.95× | -Time per call with 3.12.0 and 3.13.0, Google Benchmark suite on an Apple M1 Max, median of 8 runs; the speedup is the old time divided by the new one. The Intel column is the speedup on the Intel MacBook Pro of [Appendix G](#g-how-we-measured). The parse rows do not include destroying the result (the suite pauses the timer for that).
+Time per call with 3.12.0 and 3.13.0, Google Benchmark suite on an Apple M1 Max, +median of 8 runs; the speedup is the old time divided by the new one. The Intel column is the speedup on the Intel +MacBook Pro of [Appendix G](#g-how-we-measured). The parse rows do not include destroying the result (the suite pauses +the timer for that).
??? info "All 182 benchmarks of the suite" @@ -1766,12 +1830,13 @@ The other four workloads we followed: -Speedup over 3.12.0 after each commit that changed the headers, for the four workloads not shown in the timeline section. More is better; the badges are theme numbers.
+Speedup over 3.12.0 after each commit that changed the headers, for the four workloads +not shown in the timeline section. More is better; the badges are theme numbers.
-The parse panels include destroying the result: the benchmark returns `json::parse(text).size()`, so the changes to -the destructor ([#5762][5762], [#5835][5835]) show up there too, as steps of up to 6% in either direction. When -`accept()` of `twitter.json` got 2.5% slower over the last 20 commits, we timed the two ends against each other in -one binary with six code layouts: they were equal within 0.2%. +The parse panels include destroying the result: the benchmark returns `json::parse(text).size()`, so the changes to the +destructor ([#5762][5762], [#5835][5835]) show up there too, as steps of up to 6% in either direction. When `accept()` +of `twitter.json` got 2.5% slower over the last 20 commits, we timed the two ends against each other in one binary with +six code layouts: they were equal within 0.2%. | workload | theme | pull request | change | step | | --- | :-: | --- | --- | --: | @@ -1809,7 +1874,8 @@ one binary with six code layouts: they were equal within 0.2%. | | 6 | [#5824][5824] | a cursor instead of `vector::insert()` | −38% | | | 6 | [#5885][5885] | runs of numbers encoded in a loop | −32% | -The labeled steps of the timeline: the change in time at the first commit of each pull request, against the commit before it. [#5592][5592] is by [@mvanslobbe](https://github.com/mvanslobbe).
+The labeled steps of the timeline: the change in time at the first commit of each +pull request, against the commit before it. [#5592][5592] is by [@mvanslobbe](https://github.com/mvanslobbe).
### D. Algorithmic fixes @@ -1825,76 +1891,76 @@ one binary with six code layouts: they were equal within 0.2%. | 100,000 × `push_back()` on an `ordered_json` with [`JSON_DIAGNOSTICS`](../../api/macros/json_diagnostics.md) | 1,589 ms | 5.32 ms | 299× | every insert re-pointed the parent pointers of all elements | [#5832][5832] | | 10,000 × `at(i)` on an `ordered_json` array with `JSON_DIAGNOSTICS` | 45.9 ms | 0.088 ms | 522× | the same | [#5832][5832] | -Operations that were quadratic or worse in 3.12.0, timed with both versions (C++17, `-O3`, median of 3 interleaved runs).
+Operations that were quadratic or worse in 3.12.0, timed with both versions (C++17, +`-O3`, median of 3 interleaved runs).
### E. Comparison with other libraries **Method.** The programs are in [`tests/benchmarks/json_view/others`](https://github.com/nlohmann/json/tree/develop/tests/benchmarks/json_view/others), whose README lists every variant, the pinned versions and checksums of every library, and the build commands. The -libraries: [yyjson](https://github.com/ibireme/yyjson) 0.13.0, [simdjson](https://github.com/simdjson/simdjson) -5.1.1, [sonic-cpp](https://github.com/bytedance/sonic-cpp) 1.0.2, [RapidJSON](https://github.com/Tencent/rapidjson) -(master, 24b5e7a, as its last release is from 2016), [Glaze](https://github.com/stephenberry/glaze) 9.1.0, [Boost.JSON](https://github.com/boostorg/json) -1.92, and in Rust [sonic-rs](https://github.com/cloudwego/sonic-rs) 0.5.10, -[simd-json](https://github.com/simd-lite/simd-json) 0.19.0 and [serde_json](https://github.com/serde-rs/json) -1.0.154. C++23 with -`-O3`; on the Mac Apple clang 21 (the compiler of the current Xcode), on x86-64 GCC 16.2, GCC 13.4 and Clang 23 -with `-march=haswell` on Ubuntu 26.04, pinned to one core; Rust 1.99 with `-C target-cpu=haswell` on x86-64. The Rust times on the Mac are from -October 29; the Rust programs haven't changed since. Every -variant runs in every round, in a different order each round (a shuffle with a fixed seed), each timed call after an untimed one; the time is the -best of 2 × 30 rounds (2 × 6 for `jeopardy.json`). Boost.JSON uses a monotonic resource and precise number parsing, -RapidJSON `kParseFullPrecisionFlag` (except in *validate*, which reads no number), serde_json `float_roundtrip`: every -number a library converts has to be exact. That rule, like the others here, is our choice. Between the two runs, a -library's time relative to `json_document`'s moved by 0.8% at the median and 2.5% at the 95th percentile on the Mac, -and by 2.5–3% and 9–10% on x86-64. +libraries: [yyjson](https://github.com/ibireme/yyjson) 0.13.0, [simdjson](https://github.com/simdjson/simdjson) 5.1.1, +[sonic-cpp](https://github.com/bytedance/sonic-cpp) 1.0.2, [RapidJSON](https://github.com/Tencent/rapidjson) (master, +24b5e7a, as its last release is from 2016), [Glaze](https://github.com/stephenberry/glaze) 9.1.0, +[Boost.JSON](https://github.com/boostorg/json) 1.92, and in Rust [sonic-rs](https://github.com/cloudwego/sonic-rs) +0.5.10, [simd-json](https://github.com/simd-lite/simd-json) 0.19.0 and [serde_json](https://github.com/serde-rs/json) +1.0.154. C++23 with `-O3`; on the Mac Apple clang 21 (the compiler of the current Xcode), on x86-64 GCC 16.2, GCC 13.4 +and Clang 23 with `-march=haswell` on Ubuntu 26.04, pinned to one core; Rust 1.99 with `-C target-cpu=haswell` on +x86-64. The Rust times on the Mac are from October 29; the Rust programs haven't changed since. Every variant runs in +every round, in a different order each round (a shuffle with a fixed seed), each timed call after an untimed one; the +time is the best of 2 × 30 rounds (2 × 6 for `jeopardy.json`). Boost.JSON uses a monotonic resource and precise number +parsing, RapidJSON `kParseFullPrecisionFlag` (except in *validate*, which reads no number), serde_json +`float_roundtrip`: every number a library converts has to be exact. That rule, like the others here, is our choice. +Between the two runs, a library's time relative to `json_document`'s moved by 0.8% at the median and 2.5% at the 95th +percentile on the Mac, and by 2.5–3% and 9–10% on x86-64. -**Validation levels.** A way validates fully if it rejects all four broken inputs (cut short by one byte, a -stray comma, an invalid UTF-8 byte in a string, garbage after the value); "U" accepts the invalid UTF-8 byte; "P" -accepts one of the other three. The probes separate the ways that check everything from those that check less; -JSONTestSuite is the conformance check. sonic-cpp 1.0.2 does not check UTF-8. RapidJSON does only with `kParseValidateEncodingFlag`, and +**Validation levels.** A way validates fully if it rejects all four broken inputs (cut short by one byte, a stray comma, +an invalid UTF-8 byte in a string, garbage after the value); "U" accepts the invalid UTF-8 byte; "P" accepts one of the +other three. The probes separate the ways that check everything from those that check less; JSONTestSuite is the +conformance check. sonic-cpp 1.0.2 does not check UTF-8. RapidJSON does only with `kParseValidateEncodingFlag`, and serde_json reading into `IgnoredAny` or structs from bytes doesn't check the strings it skips, unless the input is checked with `std::str::from_utf8` first; both are shown both ways. simdjson's On-Demand API, Glaze's `lazy_json`, `simdjson::minify()` and Glaze's `minify_json()` don't validate what they skip, Glaze's structs only with -`validate_skipped`, and Glaze's generic reader rejects garbage after the value only with -`validate_trailing_whitespace` (its strict variants also set `escape_control_characters`, without which it writes -control characters unescaped). +`validate_skipped`, and Glaze's generic reader rejects garbage after the value only with `validate_trailing_whitespace` +(its strict variants also set `escape_control_characters`, without which it writes control characters unescaped). **Conformance.** Every *validate* way ran the 318 files of [JSONTestSuite](https://github.com/nst/JSONTestSuite), on the -Mac with the headers of November 3 and on x86-64 with those of October 30 (the Mac's results didn't change). No -way rejected a file that must be accepted. Of those that must be rejected, a few got through: `123` followed by a NUL -byte in nlohmann/json and RapidJSON (nlohmann/json treats NUL as the end of the input; -[`JSON_STRICT_NUL_HANDLING`](../../api/macros/json_strict_nul_handling.md) changes that for `json` and `json_document`), an incomplete UTF-8 -byte-order mark in RapidJSON, an invalid `\u` escape in sonic-rs's `IgnoredAny`, and, in 17 files, garbage after the -value in Glaze's generic reader without `validate_trailing_whitespace`. sonic-rs, and RapidJSON's SAX reader with GCC -16.2 on x86-64, crash with a stack overflow on 100,000 nested arrays. Neither machine has AVX-512, so simdjson's fastest -kernel never ran, and yyjson and simdjson are compiled as separate files without link-time optimization, while the -header-only libraries inline into the benchmark. Built with link-time optimization, and simdjson as C++23 like the -rest, they moved by less than 3% against `json_document` on average (Apple clang and GCC 16). +Mac with the headers of November 3 and on x86-64 with those of October 30 (the Mac's results didn't change). No way +rejected a file that must be accepted. Of those that must be rejected, a few got through: `123` followed by a NUL byte +in nlohmann/json and RapidJSON (nlohmann/json treats NUL as the end of the input; +[`JSON_STRICT_NUL_HANDLING`](../../api/macros/json_strict_nul_handling.md) changes that for `json` and `json_document`), +an incomplete UTF-8 byte-order mark in RapidJSON, an invalid `\u` escape in sonic-rs's `IgnoredAny`, and, in 17 files, +garbage after the value in Glaze's generic reader without `validate_trailing_whitespace`. sonic-rs, and RapidJSON's SAX +reader with GCC 16.2 on x86-64, crash with a stack overflow on 100,000 nested arrays. Neither machine has AVX-512, so +simdjson's fastest kernel never ran, and yyjson and simdjson are compiled as separate files without link-time +optimization, while the header-only libraries inline into the benchmark. Built with link-time optimization, and simdjson +as C++23 like the rest, they moved by less than 3% against `json_document` on average (Apple clang and GCC 16). -**Choices.** A library's time is its fastest fully validating way per file; "(U …)", "(P …)" and "(N …)" give a -faster way that checks or does less. A cell of the figures is the geometric mean of a library's four times, divided by the smallest -such mean in its column. Variants that keep a parser or document across calls are measured but not chosen, and so are in-place -ways with the copy made before the timer starts; with every library reused, the first place in each column stays the -same. Variants that leave numbers -unconverted while reading are chosen where they pass the checks (yyjson's and RapidJSON's raw numbers, Boost.JSON's -`number_precision::none`). Variants that write each number by copying its text instead of formatting it -(`json_document` with `number_format::source`, yyjson's and RapidJSON's raw numbers) count as doing less, "N": their -output keeps the source's spelling of each number. They matter most for `canada.json`. Glaze's `generic` with `double` -numbers fails the checks wherever it has to write the 64-bit ids of `twitter.json`. The struct variants of -nlohmann/json, Glaze, simdjson, and serde read into the same types; Boost.JSON's `parse_into` has no type that skips a -value, so it holds a little more. +**Choices.** A library's time is its fastest fully validating way per file; "(U …)", "(P …)" and "(N …)" give a faster +way that checks or does less. A cell of the figures is the geometric mean of a library's four times, divided by the +smallest such mean in its column. Variants that keep a parser or document across calls are measured but not chosen, and +so are in-place ways with the copy made before the timer starts; with every library reused, the first place in each +column stays the same. Variants that leave numbers unconverted while reading are chosen where they pass the checks +(yyjson's and RapidJSON's raw numbers, Boost.JSON's `number_precision::none`). Variants that write each number by +copying its text instead of formatting it (`json_document` with `number_format::source`, yyjson's and RapidJSON's raw +numbers) count as doing less, "N": their output keeps the source's spelling of each number. They matter most for +`canada.json`. Glaze's `generic` with `double` numbers fails the checks wherever it has to write the 64-bit ids of +`twitter.json`. The struct variants of nlohmann/json, Glaze, simdjson, and serde read into the same types; Boost.JSON's +`parse_into` has no type that skips a value, so it holds a little more.The use cases on the x86-64 server with Clang 23. Less is better; hover over or tap a cell for the times and the way.
+The use cases on the x86-64 server with Clang 23. Less is better; hover over or tap a +cell for the times and the way.
The use cases on the x86-64 server with GCC 13.4. Less is better; hover over or tap a cell for the times and the way.
+The use cases on the x86-64 server with GCC 13.4. Less is better; hover over or tap a +cell for the times and the way.
??? info "Times in µs on the Apple M1 Max" @@ -2290,22 +2356,22 @@ Visible changes that came with the speed: - **Float output** ([#5634][5634]). Żmij writes the shortest decimal that reads back as the same `double`, and the closest one if there are several. Of one million random `double` bit patterns, 1,425 (0.14%) are now written - differently: 810 with fewer digits (`5.3165205877497296e+16` becomes `5.31652058774973e+16`), 615 with the same - number of digits but the closer ones. They all read back as the same `double`, and none of one million short - decimals changes. In `canada.json`, 69 of the 111,126 numbers come out differently. -- **Error positions** ([#5283][5283]). The column reported after a number at the end of a line is now correct; the - fast paths otherwise report the same byte, line, and column as the byte path, checked on 481,000 documents. -- **SAX float text** ([#5852][5852]). The built-in handlers behind `parse()` and `accept()` get an empty string as - the text of a float token. A user-written SAX handler still gets the token's text. -- **`FILE*` locking** ([#5828][5828]). The lock is taken once and held for the whole parse instead of once per byte. - The documentation mentions it, and an opt-out macro restores the old behavior. + differently: 810 with fewer digits (`5.3165205877497296e+16` becomes `5.31652058774973e+16`), 615 with the same number + of digits but the closer ones. They all read back as the same `double`, and none of one million short decimals + changes. In `canada.json`, 69 of the 111,126 numbers come out differently. +- **Error positions** ([#5283][5283]). The column reported after a number at the end of a line is now correct; the fast + paths otherwise report the same byte, line, and column as the byte path, checked on 481,000 documents. +- **SAX float text** ([#5852][5852]). The built-in handlers behind `parse()` and `accept()` get an empty string as the + text of a float token. A user-written SAX handler still gets the token's text. +- **`FILE*` locking** ([#5828][5828]). The lock is taken once and held for the whole parse instead of once per byte. The + documentation mentions it, and an opt-out macro restores the old behavior. ### G. How we measured **What is compared.** "3.12.0" is the released single header and "3.13.0" the released one. Both are compiled from the *same* benchmark source, the Google Benchmark suite in -[`tests/benchmarks`](https://github.com/nlohmann/json/tree/develop/tests/benchmarks), with the BON8 benchmarks -removed because 3.12.0 has no BON8. +[`tests/benchmarks`](https://github.com/nlohmann/json/tree/develop/tests/benchmarks), with the BON8 benchmarks removed +because 3.12.0 has no BON8. | | | | --- | --- | @@ -2321,20 +2387,19 @@ removed because 3.12.0 has no BON8. **The other measurements.** The timeline is [`tests/benchmarks/history`](https://github.com/nlohmann/json/tree/develop/tests/benchmarks/history): one program, -compiled against `single_include` of 3.12.0, of each of the 351 commits on `develop` up to November 3 that changed `json.hpp` or -`json_view.hpp` since, and of 3.13.0 (C++17, `-O3`, no LTO), 4 rounds in random order, median of the rounds (the mean of the two middle ones). Two -commits that don't compile on their own are left out. The commits up to October 27 were run on October 27, the -later ones and 3.13.0 on November 3, together with twelve of the earlier commits as a check: per workload, their -median moved by 1.5% or less. The -algorithmic fixes come from small extra programs (C++17, `-O3`, median of 3 interleaved runs). The comparison with -other libraries is described in [Appendix E](#e-comparison-with-other-libraries). +compiled against `single_include` of 3.12.0, of each of the 351 commits on `develop` up to November 3 that changed +`json.hpp` or `json_view.hpp` since, and of 3.13.0 (C++17, `-O3`, no LTO), 4 rounds in random order, median of the +rounds (the mean of the two middle ones). Two commits that don't compile on their own are left out. The commits up to +October 27 were run on October 27, the later ones and 3.13.0 on November 3, together with twelve of the earlier commits +as a check: per workload, their median moved by 1.5% or less. The algorithmic fixes come from small extra programs +(C++17, `-O3`, median of 3 interleaved runs). The comparison with other libraries is described in [Appendix +E](#e-comparison-with-other-libraries). -**Compile times.** The two programs of [The price](#the-price), each compiled as one translation unit with -`-std=c++20` (modules need it) and `-O0` or `-O2`, with Apple clang 21 and Homebrew GCC 16.2 on the M1 Max. The time is -the CPU time (user plus system) of the compiler process, the median of 5 rounds in which all 40 compilations ran in a -random order. The slowest of the 5 runs was within 13% of the fastest for every program and every build of the -precompiled header or the module. Every variant was also linked -and run once, with the same output from all of them. +**Compile times.** The two programs of [The price](#the-price), each compiled as one translation unit with `-std=c++20` +(modules need it) and `-O0` or `-O2`, with Apple clang 21 and Homebrew GCC 16.2 on the M1 Max. The time is the CPU time +(user plus system) of the compiler process, the median of 5 rounds in which all 40 compilations ran in a random order. +The slowest of the 5 runs was within 13% of the fastest for every program and every build of the precompiled header or +the module. Every variant was also linked and run once, with the same output from all of them. | build | program | 3.12.0 | 3.13.0 | 3.13.0, PCH | 3.13.0, module | | --- | --- | --: | --: | --: | --: | @@ -2347,7 +2412,9 @@ and run once, with the same output from all of them. | GCC 16 `-O2` | `count_numbers` | 2,336 | 3,702 | 2,987 | 2,913 | | | `server_config` | 3,122 | 4,714 | 4,006 | 4,055 | -Compile time in milliseconds of the two programs in [The price](#the-price) (CPU time of the compiler, median of 5 runs). Building the precompiled header took 973 and 987 ms with Apple clang (`-O0`, `-O2`) and 2,565 and 2,589 ms with GCC; building the module took 1,617 and 1,602 ms or 2,499 and 2,465 ms.
+Compile time in milliseconds of the two programs in [The price](#the-price) (CPU +time of the compiler, median of 5 runs). Building the precompiled header took 973 and 987 ms with Apple clang (`-O0`, +`-O2`) and 2,565 and 2,589 ms with GCC; building the module took 1,617 and 1,602 ms or 2,499 and 2,465 ms.
??? example "The programs and the build commands" @@ -2469,70 +2536,76 @@ and run once, with the same output from all of them. g++-16 -std=c++20 -O2 -fmodules -DUSE_MODULE -c count_numbers.cpp ``` -The commands for one program at `-O2`; the module variants are linked with `json_mod.o`.
+The commands for one program at `-O2`; the module variants are linked with + `json_mod.o`.
**What we did not measure.** The timeline and the algorithmic fixes ran on one Apple silicon laptop with Clang, the -suite also on an Intel laptop with Clang. The comparison with other libraries also ran on an x86-64 server with GCC 16.2, GCC 13, and Clang 23, and -on the Mac with GCC 16.2 and Clang 23 as a check. That server is a virtual machine with 8 cores that present -themselves as Haswell; its timings show the penalty of the microcode fix for Intel's JCC erratum, 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. +suite also on an Intel laptop with Clang. The comparison with other libraries also ran on an x86-64 server with GCC +16.2, GCC 13, and Clang 23, and on the Mac with GCC 16.2 and Clang 23 as a check. That server is a virtual machine with +8 cores that present themselves as Haswell; its timings show the penalty of the microcode fix for Intel's JCC erratum, +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. ### 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]. -- 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]. -- 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]. -- 4 **Don't do unnecessary work:** float token text [#5852][5852]; token buffer [#5234][5234], [#5828][5828]; - positions on error [#5809][5809]; - binary readers' copy [#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]. -- 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]. -- 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 recursion - [#5285][5285], [#5389][5389], [#5547][5547]; destructor [#5762][5762], [#5835][5835]. -- 8 **Build a different data structure:** `json_document` [#5620][5620], [#5627][5627], [#5628][5628], - [#5622][5622], [#5908][5908], [#5879][5879]; `accept()` without an index [#5970][5970]; reading and indexing [#5888][5888], - [#5915][5915], [#5889][5889], [#5890][5890]; editing [#5630][5630], [#5919][5919], [#5926][5926]. -- **Validation:** guard pages [#5826][5826]; differential fuzzing [#5827][5827]; float checks - [#5617][5617], [#5908][5908]; mutation checks [#5878][5878]. +- 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]. +- 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]. +- 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]. +- 4 **Don't do unnecessary work:** float token text + [#5852][5852]; token buffer [#5234][5234], [#5828][5828]; positions on error [#5809][5809]; binary readers' copy + [#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]. +- 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]. +- 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 + recursion [#5285][5285], [#5389][5389], [#5547][5547]; destructor [#5762][5762], [#5835][5835]. +- 8 **Build a different data structure:** `json_document` + [#5620][5620], [#5627][5627], [#5628][5628], [#5622][5622], [#5908][5908], [#5879][5879]; `accept()` without an index + [#5970][5970]; reading and indexing [#5888][5888], [#5915][5915], [#5889][5889], [#5890][5890]; editing [#5630][5630], + [#5919][5919], [#5926][5926]. +- **Validation:** guard pages [#5826][5826]; differential fuzzing [#5827][5827]; float checks [#5617][5617], + [#5908][5908]; mutation checks [#5878][5878]. - **The UTF-8 check that came and went:** [#5185][5185], [#5531][5531], [#5741][5741], [#5746][5746]. ### I. Details of the themes -**Eisel-Lemire (theme 3).** [fast_float](https://github.com/fastfloat/fast_float)'s `compute_float`, which 3.13.0 -adapts, works with integers only. The value is `w · 10^q = w · 5^q · 2^q`, so it normalizes `w` to 64 significant -bits, multiplies it with a 128-bit approximation of `5^q` from a table of 651 entries, and takes the 53 bits of the mantissa plus one rounding -bit from the top of the product. The binary exponent comes from a fixed-point `log2(10)`. If the nine bits below the -rounding bit are all ones, the truncated part of the table entry could still carry into the result; then a second -multiplication with the next 64 bits of `5^q` decides, which happens for about one input in 512. Mushtak and Lemire -showed in 2023 that for up to 19 digits these 128 bits always suffice, so the algorithm needs no fallback. Exact ties -between two `double`s, the only place where rounding half to even matters, can only occur for small exponents, and -are handled there. (Clinger's fast path, by contrast, uses the floating-point unit and assumes the default rounding -mode.) +**Eisel-Lemire (theme 3).** +[fast_float](https://github.com/fastfloat/fast_float)'s `compute_float`, which 3.13.0 adapts, works with integers only. +The value is `w · 10^q = w · 5^q · 2^q`, so it normalizes `w` to 64 significant bits, multiplies it with a 128-bit +approximation of `5^q` from a table of 651 entries, and takes the 53 bits of the mantissa plus one rounding bit from the +top of the product. The binary exponent comes from a fixed-point `log2(10)`. If the nine bits below the rounding bit are +all ones, the truncated part of the table entry could still carry into the result; then a second multiplication with the +next 64 bits of `5^q` decides, which happens for about one input in 512. Mushtak and Lemire showed in 2023 that for up +to 19 digits these 128 bits always suffice, so the algorithm needs no fallback. Exact ties between two `double`s, the +only place where rounding half to even matters, can only occur for small exponents, and are handled there. (Clinger's +fast path, by contrast, uses the floating-point unit and assumes the default rounding mode.) Numbers with more than 19 digits are rare in JSON. For them, Eisel-Lemire runs on the first 19 digits, `w`, and on -`w + 1`: the true value lies between the two, so if both give the same `double`, that is the answer. Only if they -differ does fast_float's digit comparison build the exact midpoint between the two candidates as a big integer and -compare all digits with it. +`w + 1`: the true value lies between the two, so if both give the same `double`, that is the answer. Only if they differ +does fast_float's digit comparison build the exact midpoint between the two candidates as a big integer and compare all +digits with it. -**Żmij's digits (theme 3).** Żmij's digit conversion, by Xiang JunBo, is the same divide-by-multiplying idea as the -parser's, run backwards on all lanes of a word at once: +**Żmij's digits (theme 3).** Żmij's digit conversion, by Xiang +JunBo, is the same divide-by-multiplying idea as the parser's, run backwards on all lanes of a word at once: ```cpp // 12345678 -> the bytes 1, 2, 3, 4, 5, 6, 7, 8 (one digit per byte) @@ -2549,15 +2622,16 @@ std::uint64_t eight_digit_bytes(std::uint64_t abcdefgh) noexcept } ``` -Żmij's conversion of an eight-digit number to one digit per byte, in three steps of divide-by-multiplying.
+Żmij's conversion of an eight-digit number to one digit per byte, in three steps of +divide-by-multiplying.
`(x · ⌈2^k / d⌉) >> k` is `x / d` for the range that occurs, and `x + (2^w − d) · (x / d)` rewrites `x` as `(x / d) · 2^w + x mod d`: the quotient moves up into the next lane. Three steps split 8 digits into 4 + 4, 2 + 2 + 2 + 2, and single digits. A zero digit becomes a zero byte, so leading and trailing zeros are counted with a count of leading or trailing zero bits instead of divisions. -**The destructor's walk (theme 7).** While the destructor's walk empties a container, the slot of that container's -last child holds the link back to the container's parent: +**The destructor's walk (theme 7).** While the destructor's walk +empties a container, the slot of that container's last child holds the link back to the container's parent: ```cpp // descend into the last child of cur, reversing the link @@ -2572,43 +2646,46 @@ take(cur, tmp); // the child becomes curOne step down in the destructor's walk: the emptied slot remembers the way back.
`take()` moves the 16 bytes of type and value and leaves a null behind. The way back needs no memory: the slot that -holds the link is the one being emptied, it stays the last child until the walk comes back up, and only then, after -the link is taken out, is it removed. A child is removed only when it is a scalar or an empty container, so each -removal destroys one level at most. +holds the link is the one being emptied, it stays the last child until the walk comes back up, and only then, after the +link is taken out, is it removed. A child is removed only when it is a scalar or an empty container, so each removal +destroys one level at most. ### J. Validation A faster path is only useful if it gives the same result as the slow one, including the same error at the same byte. These are the checks behind the themes: -- **Error positions** (theme 1). The contiguous fast paths defer to the byte path on anything malformed. On 481,000 - documents, 298,333 of them with errors, the contiguous and the byte path reported the same exception, message, byte, - line and column, except for the one deliberate correction listed in [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. +- **Error positions** (theme 1). The contiguous fast paths defer + to the byte path on anything malformed. On 481,000 documents, 298,333 of them with errors, the contiguous and the byte + path reported the same exception, message, byte, line and column, except for the one deliberate correction listed in + [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. - **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]). -- **Guard pages.** The input is copied so that its last byte is the last readable byte of a page, and the next page - is unmapped. AddressSanitizer misses an over-read into the slack of the same heap allocation, and fuzzers rarely end - an input exactly at a page boundary; this test crashes on any over-read by one byte ([#5826][5826]). -- **Number conversion** (theme 3). 6.5 million float tokens were compared with `strtod`, among them 60,000 exact - halfway cases, and 900,000 hard cases from other test suites: all bit-identical. `json_document`'s batched float - output is byte-identical to the general writer for 10⁹ generated tokens. -- **Unchanged output.** For this post, we checked that 3.13.0 writes the same bytes as 3.12.0 in all five binary - formats for 39 test files, and the same JSON text for `twitter.json` and `citm_catalog.json`. The changes of float - output are listed in [Appendix F](#f-behavior-changes). +- **Guard pages.** The input is copied so that its last byte is the last readable byte of a page, and the next page is + unmapped. AddressSanitizer misses an over-read into the slack of the same heap allocation, and fuzzers rarely end an + input exactly at a page boundary; this test crashes on any over-read by one byte ([#5826][5826]). +- **Number conversion** (theme 3). 6.5 million float tokens were + compared with `strtod`, among them 60,000 exact halfway cases, and 900,000 hard cases from other test suites: all + bit-identical. `json_document`'s batched float output is byte-identical to the general writer for 10⁹ generated + tokens. +- **Unchanged output.** For this post, we checked that 3.13.0 writes the same bytes as 3.12.0 in all five binary formats + for 39 test files, and the same JSON text for `twitter.json` and `citm_catalog.json`. The changes of float output are + listed in [Appendix F](#f-behavior-changes). - **`json_document` against `json`.** A fuzzer parses every input with both and requires the same verdict, the same value and the same error message, from a borrowed string and from an exact-size buffer without a closing NUL ([`fuzzer-parse_json_view.cpp`](https://github.com/nlohmann/json/blob/develop/tests/src/fuzzer-parse_json_view.cpp)). -- **Comparison** (theme 7). The single-pass `operator<` was compared with the old code on 68,121 pairs of values. +- **Comparison** (theme 7). The single-pass `operator<` was + compared with the old code on 68,121 pairs of values. - **Mutation checks.** Break a fast path on purpose (skip one byte too many, mark `"` as copyable) and make sure the tests notice. Marking the quote as copyable in the short-string table fails 4,551 assertions. -- **The comparison with other libraries.** Before anything is timed, every way's result is checked: the same values, - the same string bytes, the same sum of all numbers (to the last bit where they are added in the same order), and an output that reads back as the expected - document. The expected values come from nlohmann/json; the Rust programs compute their own with serde_json, and - the two agree ([Appendix E](#e-comparison-with-other-libraries)). +- **The comparison with other libraries.** Before anything is timed, every way's result is checked: the same values, the + same string bytes, the same sum of all numbers (to the last bit where they are added in the same order), and an output + that reads back as the expected document. The expected values come from nlohmann/json; the Rust programs compute their + own with serde_json, and the two agree ([Appendix E](#e-comparison-with-other-libraries)). [5185]: https://github.com/nlohmann/json/pull/5185 [5233]: https://github.com/nlohmann/json/pull/5233