535 KiB
How nlohmann/json 3.13.0 got 3× faster
Since 2016, the README of this library has said this about speed:
There are certainly faster JSON libraries out there. However, if your goal is to speed up your development by adding JSON support with a single header, then this library is the way to go.
That sentence is still true, but the gap is a lot smaller now. Swap the 3.12.0 header for the 3.13.0 one, recompile, and
the 182 benchmarks of the library's own suite run 3.0× as fast on an Apple M1 Max (geometric mean; 3.1× on an Intel
MacBook Pro from 2016). Not a line of your code changes. Parsing typical documents got 3.1–4.3× as fast, and writing
them 3.0–6.7×. The new version also adds json_document, a second way to hold a parsed
document, which competes with the fastest C++ and Rust JSON libraries; How it compares has the
numbers, including where it loses. The price is compile time: two small example programs take 34–62% longer to compile.
A precompiled header or a C++20 module wins back most or all of that in debug builds, and 41–91% of it in optimized
builds (The price).
This post is about how. Hardly any of the techniques are new: we borrowed them from
simdjson, yyjson,
fast_float, Żmij,
serde_json, Glaze,
SQLite, Boost.JSON, or a textbook, and we name the source at
each step. What is worth writing about is the combination, and the conditions it had to work under. It is a
header-only C++11 library whose 3.x API has been stable for almost nine years, and whose value type is a template over
its string, number, object, array, and allocator types. Input comes from strings, streams, FILE*, iterator pairs, wide
strings, and user-defined adapters. Output had to stay the same byte for byte, and error messages had to keep pointing
at the same byte. The few deliberate changes of output are listed in Appendix F.
Every number comes from a benchmark we ran for this post or from the pull request that made the change. The appendix describes the test files and the setup, has the full tables behind every chart, and lists the pull requests behind each theme.
Fast paths are where bugs hide: they run only for some inputs, so a mistake in one can go unnoticed until the right input comes along. So every fast path was tested against the slow path it replaces, on millions of generated and fuzzed inputs. Both had to return the same value, or fail with the same error message at the same byte. Appendix J lists the checks.
The short version
The test files are the three of Milo Yip's nativejson-benchmark that
JSON libraries have been measured with for a decade, twitter.json (strings, much of it Japanese), citm_catalog.json
(many small objects, indented) and canada.json (floating-point numbers), plus the 56 MB jeopardy.json. Appendix
A says what makes each of them hard.
Speedup of 3.13.0 over 3.12.0 for a selection of workloads: the old time divided by the new one, Google Benchmark suite on an Apple M1 Max, median of 8 runs. More is better; the vertical line at 1× is 3.12.0. The times are in [Appendix B](#b-3120-against-3130).
The rows above are chosen to show the range. All 182 benchmarks of the suite:
How many of the suite's 182 benchmarks got how much faster. The one below 1× is copying `citm_catalog.json` without destroying the copy. Every row is in [Appendix B](#b-3120-against-3130).
The median benchmark got 2.9× as fast. On an Intel MacBook Pro (Skylake), the median is 2.9× as well, but parsing
floating-point numbers gained more (canada.json 6.1× instead of 3.4×) and writing integers less (dump() 1.4–2.3×
instead of 2.2–3.8×); Appendix B has both columns. Copying and comparing values gained the
least, and the largest gains come from fixing algorithms rather than tuning them (theme
7).
Eight themes did most of the work:
- 1 Specialize for each input source: a dedicated
reader for each of strings, streams, and
FILE*, instead of one character at a time for all of them. - 2 Work on words, not bytes: 8 bytes per step with plain integer arithmetic, 16 with SSE2 and NEON.
- 3 Own the number conversions: no
strtod, no locale. - 4 Don't do unnecessary work: positions, copies, and conversions that only matter on an error or for a rare caller.
- 5 Read the assembly: stack canaries, missed inlining, aliasing, and branches the compiler chose.
- 6 Write through a cursor: no virtual call per character, no allocation per call, no branch per integer.
- 7 Fix the algorithm first: seven operations were quadratic or worse.
- 8 Build a different data structure when the tree is the cost.
Where the time went: a timeline
The suite compares two points. To see which change did what, we compiled one small benchmark against the header of each
of the 351 commits on develop that changed json.hpp or json_view.hpp from 3.12.0 to November 3, and of 3.13.0, and
ran all of them interleaved. Four of the eight workloads we followed:
parse()ofcitm_catalog.json: the lexer's structure and whitespace handling, and building many small objects;parse()ofcanada.json: converting text to floating-point numbers;dump()oftwitter.json: writing strings;from_cbor()oftwitter.json: reading a binary format.
Each panel shows the speedup over 3.12.0 after each commit, on a logarithmic scale. The badges above the steps name the theme below that explains them. Moving code around changes a build's speed by a few percent on its own, so only the larger steps mean something.
Speedup over 3.12.0 after each of the 351 commits that changed the headers up to November 3, from 3.12.0 (left) to 3.13.0 (right). More is better; each point is the median of 4 runs of one build, taken in random order (with 4 runs, the mean of the two middle ones). The badges are theme numbers; "U" is a UTF-8 check that came and went. The other four workloads, and every labeled step with its pull request and size, are in [Appendix C](#c-the-timeline).
A few things stand out. A percentage for a step is relative to the time just before that step, not to the time of 3.12.0:
- There is no single big step. Parsing
citm_catalog.jsongot faster in about six visible steps. The largest removed a buffer that only error messages read, which alone saved 35% of the time. The next ones added the fast paths for contiguous input, derived positions only on error, and skipped indentation eight bytes at a time; most other changes moved it by a few percent. - Writing had a few large steps.
dump()oftwitter.jsonlost 70% of its time in one change (a write buffer and bulk string copies), anddump()ofcanada.json65% in another (Żmij). - Regressions happen in between. A UTF-8 check of every text string in the binary readers, byte by byte, made
from_cbor()oftwitter.json37% slower for 66 commits, until the readers were made lenient again by default, as in 3.12.0, with the check as an option. That decision was about the format specifications, which don't require a decoder to check, not about speed. The cost went with it. It never shipped in a release. - The input decides how big a step is. Removing the error-message buffer measured 8% on the benchmark of its pull
request; on the indented
citm_catalog.jsonit saved 35%, because every space of the indentation went through that buffer.
1 Specialize for each input source
This is the function the 3.12.0 lexer called for every byte of input:
char_int_type get()
{
++position.chars_read_total;
++position.chars_read_current_line;
if (next_unget)
{
// just reset the next_unget variable and work with current
next_unget = false;
}
else
{
current = ia.get_character();
}
if (JSON_HEDLEY_LIKELY(current != char_traits<char_type>::eof()))
{
token_string.push_back(char_traits<char_type>::to_char_type(current));
}
if (current == '\n')
{
++position.lines_read;
position.chars_read_current_line = 0;
}
return current;
}
The 3.12.0 lexer's `get()`, called for every byte of input.
Two counters in memory, a branch for a pending unget(), a call into the input adapter, a push_back() into a buffer
that only an error message reads, and a branch for line breaks. The counters are members of the lexer, so the compiler
has to load, add, and store them for every byte. A string or a number then copied each byte a second time into their
token buffer. That is a clean design: one interface works for a std::string, an std::istream, a FILE*, a pair of
iterators over a std::list<char>, or a user's own adapter. But it makes every input pay for the most limited one.
3.13.0 has a dedicated reader for each kind of input and picks it at compile time:
flowchart LR
src[/"input"/] --> q{{"what is it?"}}
q -->|"std::string, std::vector,<br>pointers, contiguous ranges"| c["contiguous:<br>scan the whole buffer"]
q -->|"std::istream"| w["window:<br>scan the streambuf's get area"]
q -->|"FILE*"| f["one lock per parse,<br>unlocked reads"]
q -->|"other iterators,<br>user adapters"| b["byte at a time"]
The four kinds of input the lexer distinguishes in 3.13.0. Everything on the fast paths falls back to the byte path on anything unusual, so the result and the error messages stay the same.
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:
template<typename A> using detect_supports_seek = decltype(A::supports_seek);
template<typename A>
constexpr bool input_adapter_supports_seek(std::true_type) { return A::supports_seek; }
template<typename A>
constexpr bool input_adapter_supports_seek(std::false_type) { return false; }
static constexpr bool seekable = input_adapter_supports_seek<InputAdapterType>(
is_detected<detect_supports_seek, InputAdapterType> {});
static constexpr bool lazy_position = 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_constant<bool, lazy_position>{}),
which also keeps MSVC from warning about constant conditions. get() still looks like the old one, but its position and
token bookkeeping are now calls. For an adapter that can seek, their std::true_type overloads are empty and cost
nothing, because the position and the text of a token are derived only when an error message needs them (theme
4). For a std::string, what remains is the branch on
next_unget and the adapter's bounds check and pointer increment.
Contiguous input
Containers with data() and size() over single-byte elements now become a pointer range in every C++ standard, not
only with C++20's contiguous iterators. data() and size() alone would be duck typing, so the trait also requires the
container's value_type to be the element type behind data(). A type whose size() counts something else keeps the
iterator path: slower, but correct. On a pointer range, the strings, the numbers and the indentation after a line break
are scanned in bulk (themes 2 and
3). Every fast path defers to the byte path on anything
malformed, so error messages and byte positions stay the same, with one deliberate correction (the column after a number
at the end of a line). Appendix J describes how that was checked.
Streams
An std::istream already has a buffer: the get area of its streambuf, gptr() to egptr(). The lexer now scans that
window in bulk and keeps the current token as a pointer into it. Those are protected members of std::streambuf, and
reaching them takes neither a cast nor a derived object:
struct streambuf_access : std::streambuf
{
static const char* get_ptr(std::streambuf* s) { return (s->*(&streambuf_access::gptr))(); }
static const char* get_end(std::streambuf* s) { return (s->*(&streambuf_access::egptr))(); }
static void bump(std::streambuf* s, int n) { (s->*(&streambuf_access::gbump))(n); }
};
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
name is used, not when the pointer is called, so it works on any std::streambuf*. The common alternative,
static_cast<streambuf_access*>(sb)->gptr(), is undefined behavior, because the object is not a streambuf_access.
Before the stream refills its get area, the adapter appends the part of the current token that is in it to a carry
buffer; only an error message ever reads the token text back. Nothing is read ahead: the adapter advances gptr() by
exactly what the lexer consumes, so the stream is left where it was before. On an M1 Max, parsing from an
std::ifstream took 1.8–2.5× as long as reading the file into a string and parsing that, in the pull request's
measurement before the change; now it takes just 9–16% longer.
FILE*
std::fgetc locks the FILE on every call. 3.13.0 locks it once per parse (flockfile, then getc_unlocked;
_lock_file and _fgetc_nolock with the Microsoft runtime). flockfile is recursive, so the binary readers' fread()
and a SAX handler's own ftell() keep working. Whereas parsing citm_catalog.json from a FILE* got 4.4× as fast on
macOS in the pull request's measurement, with glibc it got only 1.1–1.3× as fast, because glibc's per-call locking is
already cheap. Two details: MemorySanitizer builds keep fgetc, because glibc's inline getc_unlocked reads a buffer
that uninstrumented glibc filled. And a SAX handler that waits for another thread using the same FILE now deadlocks,
as the documentation says. Both paths can be switched off, with
JSON_DISABLE_STREAM_BULK_SCAN and
JSON_DISABLE_UNLOCKED_FILE_IO.
2 Work on words, not bytes
The core of a JSON lexer is "copy bytes until something interesting happens". Theme 1 gave it ranges of bytes. This theme reads them a word at a time.
Eight bytes in a word
In a string, the interesting bytes are the closing quote, a backslash, a control character, and anything above 0x7F (UTF-8). This test checks eight bytes at once with ordinary 64-bit arithmetic:
// return a word whose high bit is set in every byte of v that needs
// individual handling; zero if all 8 bytes can be copied as they are
inline std::uint64_t swar_string_special(std::uint64_t v) noexcept
{
constexpr std::uint64_t ones = 0x0101010101010101ull;
constexpr std::uint64_t high = 0x8080808080808080ull;
const std::uint64_t q = v ^ 0x2222222222222222ull; // '"'
const std::uint64_t b = v ^ 0x5C5C5C5C5C5C5C5Cull; // '\\'
const std::uint64_t has_quote = (q - ones) & ~q & high;
const std::uint64_t has_backslash = (b - ones) & ~b & high;
const std::uint64_t has_control = (v - 0x2020202020202020ull) & ~v & high; // < 0x20
const std::uint64_t has_non_ascii = v & high; // >= 0x80
return has_quote | has_backslash | has_control | has_non_ascii;
}
The 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. 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.
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
little-endian order, so the first byte of the text is the lowest byte of the word everywhere; a big-endian target
assembles the word from bytes, which compilers fold into a load and a byte swap. Every loop checks that eight bytes
remain before it loads, and finishes the remaining one to seven bytes one at a time, so nothing is read past the
input.
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.
- Numbers, eight digits per step (theme 3).
- Indentation. After a newline,
word ^ 0x2020…20is 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'sskip_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, validatingcitm_catalog.jsongot 22–30% faster. dump(), for runs of characters that need no escaping, with and withoutensure_ascii.- The binary formats, for the zero byte that ends a BSON key, and for the UTF-8 check of strings with
error_handler_t::strict. On the M1, readingcanada.jsonfrom BSON got 14% faster, and writingtwitter.jsonas UTF-8-checked CBOR 14%.
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,
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:
while (e - p >= 16)
{
const uint8x16_t in = vld1q_u8(p);
const uint8x16_t quote_or_backslash = vorrq_u8(vceqq_u8(in, vdupq_n_u8('"')),
vceqq_u8(in, vdupq_n_u8('\\')));
const uint8x16_t special = vorrq_u8(quote_or_backslash,
vcltq_s8(vreinterpretq_s8_u8(in), vdupq_n_s8(0x20)));
// each byte of special is 0x00 or 0xFF; shifting each 16-bit lane right by 4 and
// keeping its low byte leaves 4 bits per input byte
const uint8x8_t nibbles = vshrn_n_u16(vreinterpretq_u16_u8(special), 4);
const std::uint64_t bits = vget_lane_u64(vreinterpret_u64_u8(nibbles), 0);
if (bits != 0)
{
return p + (count_trailing_zeros(bits) >> 2u); // four bits per byte
}
p += 16;
}
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.
json_document, 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, because more
of its hot jumps now crossed a 32-byte boundary. The microcode fix for Intel's JCC erratum makes that slow on our
server. Assembling with -Wa,-mbranches-within-32B-boundaries shrinks the loss to about 4%, but a header-only library
can't choose the assembler flags of the programs that include it, so we left the code as it is, 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's "lookup4" algorithm (Keiser
and Lemire, 2021): 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::atomic<int>, so that neither a guarded local static nor a global constructor is
needed; threads that ask at the same time all store the same answer. On an error, the code backs up to the start of the
sequence and lets the byte path produce the error message. JSON_NO_SIMD turns all
vector code off.
Short strings
Short strings are a different problem. Most keys and many values are short: citm_catalog.json averages 8 bytes per
string and jeopardy.json 14. For them, setting up a scan costs more than the scan. The first step took the idea of
SQLite's jsonAppendString(): check each byte of a string of up to 32 bytes
against a 256-entry table of bytes that can be written unchanged, and copy the string in one go if they all pass. 32
bytes was the best threshold on the M1; 16 gained less, and 64 lost again. The second step replaced the table, where
SSE2 or NEON is available, with overlapping loads, the trick that memcpy implementations such as glibc's use for short
copies:
// 16 <= n <= 32: two loads that overlap in the middle cover the whole string
const uint8x16_t a = vld1q_u8(p);
const uint8x16_t b = vld1q_u8(p + n - 16);
const uint8x16_t special = /* '"', '\\', < 0x20 or 0x7F, in a or in b */;
if (vmaxvq_u8(special) != 0)
{
return false; // nothing has been written yet; take the general path
}
vst1q_u8(d, a);
vst1q_u8(d + n - 16, b); // the two stores overlap too, with the same bytes
Writing a string of 16–32 bytes with two overlapping loads and stores (NEON).
The 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.
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:
{{'\\', 'u', '0', '0', '0', '0', 0, 6}}, // 0x00
{{'\\', 'n', 0, 0, 0, 0, 0, 2}}, // 0x0A
{{'\\', '"', 0, 0, 0, 0, 0, 2}}, // 0x22
std::memcpy(write_buffer.data() + write_buffer_pos, escape.data(), 8); // always 8 bytes
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.
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's QUOTE_TAB, which orjson took from
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
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): 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:
flowchart TB
t["number token"] --> d{{"at most 19<br>significant digits?"}}
d -->|"yes"| c{{"digits and power of ten<br>exact as double?"}}
c -->|"yes"| cl["Clinger's fast path:<br>one multiplication or division"]
c -->|"no"| el["Eisel-Lemire:<br>128-bit integer arithmetic"]
d -->|"no"| el2["Eisel-Lemire on the first 19 digits"]
el2 --> amb{{"w and w+1 round<br>to the same double?"}}
amb -->|"yes"| done(["done"])
amb -->|"no"| big["compare with the exact midpoint<br>(big-integer arithmetic)"]
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's eight-digit conversion: three multiplications instead of eight.
// v: eight ASCII digits, the first digit in the lowest byte
std::uint32_t parse_eight_digits(std::uint64_t v) noexcept
{
v = ((v & 0x0F0F0F0F0F0F0F0Fu) * 2561u) >> 8u; // 2561 = 10 * 2^8 + 1
v = ((v & 0x00FF00FF00FF00FFu) * 6553601u) >> 16u; // 6553601 = 100 * 2^16 + 1
v = ((v & 0x0000FFFF0000FFFFu) * 42949672960001u) >> 32u; // 10000 * 2^32 + 1
return static_cast<std::uint32_t>(v);
}
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.
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
Clinger's fast path is guarded by more than the two conditions above:
#if !defined(FLT_EVAL_METHOD) || FLT_EVAL_METHOD == 0
if (exponent >= -22 && exponent <= 22 && w <= (std::uint64_t{1} << 53))
{
double value = static_cast<double>(w);
value = exponent < 0 ? value / exact_power_of_ten(-exponent)
: value * exact_power_of_ten(exponent);
return negative ? -value : value;
}
#endif
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, 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). 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 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.) Appendix J describes how the
conversion was checked against strtod.
Writing: Żmij
For output, 3.12.0 used Grisu2, which is fast but sometimes writes a 17th
digit where 16 suffice. 3.13.0 ports Żmij 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 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.
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: 0.14% of random doubles
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
A lot of what the 3.12.0 parser did was correct, cheap per call, and read by nobody in the common case.
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.
accept() 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:
std::size_t count_consumed_lines(std::size_t last_index, std::size_t& line_start) const
{
const auto to = std::next(begin, last_index);
using reverse_it = std::reverse_iterator<IteratorType>;
const auto last_newline = std::find(reverse_it(to), reverse_it(begin), '\n');
line_start = std::distance(begin, last_newline.base()); // column: offset - line_start
return std::count(begin, last_newline.base(), '\n'); // line: newlines before it
}
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 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() and accept() never look at it. The parser
now recognizes them by their exact type:
template<typename SAX> struct sax_ignores_number_text : std::false_type {};
template<typename B, typename IA, bool Collect, typename L>
struct sax_ignores_number_text<json_sax_dom_parser<B, IA, Collect, L>> : std::true_type {};
template<typename B>
struct sax_ignores_number_text<json_sax_acceptor<B>> : std::true_type {};
// json_sax_dom_callback_parser likewise
// at the call site
sax->number_float(res, number_float_text(sax)); // an empty string for the handlers above
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
call a separate function without the check. Parsing canada.json got 9–11% faster.
A copy that should have been a move
from_cbor() and the other binary readers ended like this:
return res ? result : basic_json(value_t::discarded); // 3.12.0
The 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.
ordered_json growth
ordered_json stores its members in a std::vector<std::pair<const std::string, json>>.
Because the key is const, the pair's "move" constructor copies the key and can throw. When std::vector grows, it
uses std::move_if_noexcept to keep the strong exception guarantee, so for this element type it copies every element,
including the whole subtree behind every value. The library now grows the storage itself, in two phases:
// phase 1, may throw but only touches tmp:
// copy the keys, default-construct the values, add the new element
Container tmp(this->get_allocator());
tmp.reserve((std::max)(size_type{1}, 2 * this->size()));
for (const auto& element : *this)
tmp.emplace_back(std::piecewise_construct, std::forward_as_tuple(element.first),
std::forward_as_tuple());
tmp.emplace_back(std::forward<Args>(args)...);
// phase 2, cannot throw: move-assign the values, adopt the storage
auto it = tmp.begin();
for (auto& element : *this) { it->second = std::move(element.second); ++it; }
Container::swap(tmp);
How `ordered_map` grows its storage in two phases, without copying the values.
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.
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 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 costs 100,000
arrays [1,2] at most 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), and
to_cbor() no longer creates a temporary basic_json for every object key it writes
(−55% on twitter.json, by @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, moving keys
was 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
A good part of 3.13.0 came from reading what the compiler made of the hot loops, with Clang's -Rpass-missed=inline,
profiles, and instruction counts. None of the following shows up in the source as a problem.
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:
case 't': // 3.12.0
{
std::array<char_type, 4> true_literal = {{'t', 'r', 'u', 'e'}};
return scan_literal(true_literal.data(), true_literal.size(), token_type::literal_true);
}
The 3.12.0 lexer's dispatch for `true`: a local array, and with it a stack canary.
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:
case 't': return scan_literal("rue", token_type::literal_true);
template<std::size_t N>
JSON_HEDLEY_NEVER_INLINE token_type scan_literal(const char (&rest)[N], token_type return_type);
The 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::vector<bool> nesting stack, whose push_back GCC called out of line, with
std::vector<std::uint8_t>; a bit vector saves memory that a stack of nesting levels doesn't need.
Exceptions that block inlining
A function that can throw builds the message, allocates the exception, and needs a landing pad for every temporary, even if the throw never happens:
if (JSON_HEDLEY_LIKELY(ptr != nullptr)) { return *ptr; } // 3.12.0, get_ref()
JSON_THROW(type_error::create(303, detail::concat(
"incompatible ReferenceType for get_ref, actual type is ", obj.type_name()), &obj));
The throw in 3.12.0's `get_ref()`: building the message makes the function too big to inline.
That made get_ref(), operator[],
at() 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:
if (JSON_HEDLEY_LIKELY(ptr != nullptr)) { return *ptr; }
throw_incompatible_reference_type(obj); // JSON_HEDLEY_NO_RETURN JSON_HEDLEY_NEVER_INLINE
The 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== 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: a test file of extern "C" wrappers around the library's
functions, which we compile to check what gets inlined, 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:
std::size_t pos = write_buffer_pos; // a local: no char store can reach it
// ... write_buffer[pos++] = digit; ...
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.
On a 4 MiB binary value, dump() 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
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
it. We don't know why the while loop lost the registers, only that the do-while shape gets them back.
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.
Branches the compiler chose
json_document 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:
const bool up = from > 37 * one;
const bool shorter = biased_distance(from - (up ? 10 * one : 0)) < radius;
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.
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'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:
// all ones or zero
std::uint64_t mask_if(bool b) { return std::uint64_t{0} - static_cast<std::uint64_t>(b); }
std::uint64_t select_if(bool b, std::uint64_t a, std::uint64_t c)
{
return c ^ ((a ^ c) & mask_if(b));
}
const bool shorter = biased_distance(from - (mask_if(up) & (10 * one))) < radius;
const std::uint64_t a = w - r10 + (mask_if(up) & 10u);
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.
Code size as a cost
get<std::array<int, 1000>>() used a pack expansion, {{ j.at(Idx).get<T>()... }}. A
pack expansion is unrolled before the optimizer decides anything, so once at() had become small enough to inline,
Clang emitted one function of about 28,000 instructions (with 3.12.0, about 8,000): a thousand inlined copies of the
bounds and type checks. Its time moved from 657 to 957 ns just by aligning the code differently. A loop needs 49
instructions.
!!! note "Instruction counts, not only time"
On the x86-64 machine we used for checking, moving code by a few bytes changed the time of unrelated benchmarks by
up to ±5%. Many decisions were therefore made on instruction counts (callgrind on Linux, `/usr/bin/time -l` on
macOS), checked against timings of builds with all functions and loops aligned to 64 bytes, and timed interleaved in
one binary with several code layouts. The target, applied strictly in the later changes, was: at least 3% faster,
and no benchmark more than 1% slower. Some changes accepted a small loss elsewhere and say so, such as the 7% on
GCC's `operator==` above. The branch example shows the limit of that rule: it is faster with more instructions.
6 Write through a cursor
In 3.12.0, every dump() created its output adapter with std::make_shared, built a
512-character indentation string, and the serializer wrote every structural character through a virtual call:
o->write_characters("{\n", 2);
o->write_characters(indent_string.c_str(), new_indent);
o->write_character('\"');
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:
flowchart LR
subgraph before["3.12.0"]
direction LR
d1["dump()"] --> a1["make_shared<br>output adapter"] --> v1["virtual call<br>per write"] --> s1[("std::string")]
end
subgraph after["3.13.0"]
direction LR
d2["dump()"] --> a2["adapter on<br>the stack"] --> b2["1 KiB buffer,<br>bulk append"] --> s2[("std::string")]
end
The text serializer's output path before and after the first two steps.
A buffer in the serializer
All writes go into a 1 KiB buffer that is flushed in bulk, so the virtual call happens once per KiB, and neighboring writes can be merged. Runs longer than the buffer are written straight through, so they are not copied twice.
void put_char(char c)
{
if (JSON_HEDLEY_UNLIKELY(write_buffer_pos == write_buffer.size())) { flush(); } // virtual
write_buffer[write_buffer_pos++] = c;
}
Writing one character: the virtual call happens only when the buffer is full.
No allocation per call
The adapter lives on the stack, and there is no indentation string any more: dump(4) writes the spaces straight into
the buffer. A compact dump() of a small object went from 4 heap allocations to 1 with libstdc++ (3 to 1 with libc++):
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() 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() to make one pass instead of two, because a vector can be written to at
any position: 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. Streams and other output adapters, which can only append, still take two passes.
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:
CharType* const start = oa.reserve(max_run * integer_encoding_space); // one check per run
CharType* out = start;
for (n = 0; n < max_run && it != end && it->is_number_integer(); ++n, ++it)
{
out += encode_integer(out, *it);
}
oa.commit(out - start);
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:
// 1, 2, 3 or 4 if x fits 1, 2, 4 or 8 bytes, without a branch
static unsigned integer_size_class(const std::uint64_t x) noexcept
{
static constexpr std::array<std::uint8_t, 64> size_class_by_leading_zeros = { /* ... */ };
return size_class_by_leading_zeros[count_leading_zeros(x | 1u)];
}
std::size_t encode_first_byte_and_tail(CharType* out, std::uint8_t first_byte,
unsigned size_class, std::uint64_t value) const noexcept
{
out[0] = to_char_type(first_byte);
const unsigned unused_bits = 64u - ((8u << size_class) >> 1u);
const std::uint64_t tail = is_little_endian ? byteswap(value) >> unused_bits
: value << unused_bits;
// always 8 bytes; the caller keeps the first 0, 1, 2, 4 or 8
std::memcpy(out + 1, &tail, sizeof(tail));
return ((std::size_t{1} << size_class) >> 1u) + 1;
}
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%.
7 Fix the algorithm first
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 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.
Comparing in one pass
The most instructive one is operator<. 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<=>,
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.
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() and the BSON and UBJSON writers with a stack overflow, and
in optimized builds operator==, diff() 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 at 32, serde_json at 128,
simdjson at 1024) or parse and write iteratively
(yyjson), and 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:
constexpr std::size_t recursion_depth_limit() noexcept { return 128; }
static void destroy_bounded(basic_json& v, std::size_t depth) noexcept
{
if (JSON_HEDLEY_UNLIKELY(depth >= detail::recursion_depth_limit()))
{
destroy_walk(v); // deeper than 128 levels: no recursion at all (below)
return;
}
// for each child that is a container: destroy_bounded(child, depth + 1)
free_container(v);
}
Bounded recursion: recursive down to 128 levels, iterative below.
The first 128 levels run the old recursive code, and only below that does an explicit stack, or the walk below, take
over. A document nested less deeply pays one comparison per container. Testing for equality walks std::vector and
std::map itself and passes the depth as an argument. Copying can't do that cheaply: the containers' copy constructors
call ours for every element, and a copy that inserts element by element so that it can pass the depth measured 4–16%
slower, because std::map's own copy clones its tree without comparing a single key. So copying counts the levels in a
thread_local counter instead, about ten nanoseconds per copy of a small object. Three more operations use the same
counter: converting a value from another basic_json specialization, the ordering comparisons (<, <=> and the
operators derived from them), and equality for custom container types, whose own operator== calls ours.
Destroying without a stack
The destructor's walk for deep values uses pointer reversal, the idea behind the Schorr-Waite graph marking
algorithm: 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
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×.
8 Build a different data structure
After all of the above, json::parse() 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 and json_view in a new header, <nlohmann/json_view.hpp>.
The two figures below show what each parser makes of this 54-byte text:
{"port": 6379, "price": 19.90, "tags": ["eu", "a\"b"]}
The example text of the next two figures.
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.
One flat index
The design is not ours either. simdjson's tape and 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:
struct node
{
node_kind kind; // object = 1, array = 2, string, number, ...
std::uint8_t flags; // escaped, edited, moved, ...
std::uint16_t extra; // numbers: digits before and after the point; big objects: hash index
std::uint32_t off; // where the value starts in the source text (or in the arena)
std::uint32_t len; // strings and float tokens: bytes; containers: number of elements
std::uint32_t next; // containers: number of nodes in the subtree, including this one
};
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 innextwhen 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 only a handful
of allocations, no matter how many values it has. The price is that the text must outlive the document, unless the
document owns it:
parse_copy()copies the text, and parsing an rvaluestd::stringmoves it into the document. Input that can't be read as one contiguous block of bytes, such as a stream or aFILE*, is read into a buffer that the document owns, too. 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 parsingcanada.jsonand reading every number got 4–7% faster, parsing alone got 36–90% slower, so we dropped it. A defaultdump()writes the shortest form of a float, asjsondoes;number_format::sourcecopies the source text of every number instead. - SIMD and tables as in theme 2, and
\uescapes are decoded with a table, as in yyjson. - A hash index for big objects, an idea from Boost.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()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 ontwitter.json,citm_catalog.jsonandcanada.json, and the 56 MBjeopardy.jsonno 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 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() 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 times the editable document against the mutable trees of other libraries.
How it compares
"Which JSON library parses fastest?" has no answer, because "parsing" means different things in different libraries. They hold a parsed JSON text in four different ways:
| approach | what you get | what it costs | libraries we measured |
|---|---|---|---|
| mutable tree | a value per JSON value, every string and number converted and owned; change anything | an allocation per container and often per string, all freed again | nlohmann/json json, Boost.JSON, RapidJSON, sonic-cpp, Glaze generic, yyjson's mutable document; serde_json, sonic-rs, simd-json values |
| flat document | one array of small entries in document order; read in any order, many times | a few allocations; read-only, or extra bookkeeping to edit | nlohmann/json json_document (editable as json_editable_document), yyjson, simdjson DOM |
| 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, 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.
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:
- 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.
- 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 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:
- 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.jsonon 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 ontwitter.jsonit sums and selects 2–3% faster thanjson_document. On x86-64, it tiesjson_documentin sum with Clang and takes 1.06× and 1.19× its time with GCC 16 and GCC 13. json_documentreads the string in place and keeps pointing into it. A document that owns its text (parse_copy()) costs one copy more: 3–5% on the Mac.- Reading from files and streams is not part of the comparison.
flowchart TB
f[/"test file"/] -->|"read once, untimed"| s["std::string with 64 spare bytes"]
s --> timed
subgraph timed ["timed, every call"]
direction LR
c["copy<br>(in-place ways)"] --> p["parse"] --> u["use case"] --> d["free<br>everything"]
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.
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.
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).
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.
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 fromcitm_catalog.json, the floats ofcanada.json(next point) and the size ofjeopardy.json. - Floats are converted only when read. That helps
json_documentin validate and select oncanada.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 validatescanada.jsonin 47% of the time. sum reads every number, so it is the fair comparison for floats; there,json_documentis 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 oncitm_catalog.jsonandcanada.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.jsonandcitm_catalog.json, yyjson comes closer in sum (1.11 → 1.04) but not in validate (1.28 → 1.27), andjson_documentstays first. - Number text can be copied. With
number_format::source,json_documentwrites 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 writingcanada.jsonwithjson_document4.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_documenton the Mac, and with GCC 16 on x86-64, simdjson and yyjson sum it slightly faster.
Reading into structs
A view can be converted into your own types with the same from_json() as json,
including the ones the NLOHMANN_DEFINE_TYPE_* 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() 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.
Much of Boost.JSON's lead in building a tree comes from its allocator: here it builds the tree in a
monotonic_resource, an arena that is freed in one go, which saves it 28–41%. With its default allocator, which
allocates every string, array, and object on its own as json does, boost::json::parse() with precise numbers takes
as long as json::parse() on twitter.json, 5% longer on citm_catalog.json, and 17% less time on canada.json (on
the Mac, freeing the tree included).
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). 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).
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:
- One pass over every value, with GCC. simdjson's On-Demand API sums all numbers of
twitter.json,citm_catalog.json, andcanada.jsonin 0.83–0.99×json_document's time with GCC 16 (0.86–0.91× with GCC 13). With Clang,json_documentis ahead there, except oncanada.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 asjson_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 cutsjson_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 validatesjeopardy.jsonfaster, 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.jsonin 0.87–0.97× the time (and Glaze, with GCC 13, in 0.96×), becausejson_documentfirst 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.json16% faster with GCC 16 and 1–4% faster with GCC 13 and Clang 23, and fromtwitter.json1–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.
Files and checks we didn't 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. 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).
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.
The four probes are broken inputs that tell full validation from less (Appendix E lists them). As a further check, every validate way also ran the 318 files of JSONTestSuite. Appendix E 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.
We did not measure memory use, a CPU with AVX-512, MSVC, writing from structs, or streaming input.
What didn't work
The pull requests record the ideas that measured worse, which is as useful as the ones that made it:
| idea | result |
|---|---|
| make every walk iterative | up to 20% slower on ordinary documents |
| move the key out of the lexer's reused buffer | 11.9% slower (later a win, see theme 4) |
inline scan_literal() |
Clang's dispatch grew from 233 to 278 instructions and got slower |
| short-string table for strings up to 64 bytes | lost against 32 |
| leave the serializer's buffers uninitialized | timings moved 18% apart in opposite directions on two similar workloads, so dropped; done later, when instruction counts showed 170 instructions saved per dump() |
| move the throws of the JSON Pointer lookups out of line too | no measurable gain, dropped; they stay inline |
json_document: an "all ASCII" flag per string |
dump() with ensure_ascii faster, but parsing 1–13% slower |
json_document: convert floats while parsing |
canada.json parse 36–90% slower |
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.
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:
count_numbersreadstwitter.jsonand adds up all of its numbers, like the sum use case in How it compares;server_configreads a configuration file into a struct withto_json()andfrom_json(), changes it, and writes it back withdump(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):
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:
- 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 still depends heavily on the compiler version, and macros don't cross a module boundary: withimport nlohmann.json;, theNLOHMANN_DEFINE_TYPE_*macros aren't available, which is whyserver_configwritesto_json()andfrom_json()by hand.
The compile-time page measures these and the other options, such as including
<nlohmann/json_fwd.hpp> in headers that only name json, and
JSON_NO_AUTOMATIC_UDLS. We did not measure binary size or peak memory.
Summing up
Hardly any of the techniques in this post are new. What we think made the difference is their combination, under the conditions of a header-only C++11 library whose API and output had to stay the same.
Thanks
Most of what is in this post was invented by other people.
Code we adapted. The library contains code adapted from these four projects. We use it under the MIT License, and the source files that contain it carry the authors' copyright lines and say what was taken; the README lists them too.
- fast_float (Apache-2.0, MIT, or BSL-1.0) by Daniel Lemire and contributors: the Eisel-Lemire algorithm, its table of powers of five, its digit comparison for long numbers, and its parsing of eight digits at a time (N. Mushtak and D. Lemire, "Fast number parsing without fallback", 2023). W. D. Clinger for the fast path, M. Eisel for the algorithm.
- Żmij (MIT) by Victor Zverovich, with the digit conversion of Xiang JunBo, the NEON sequence and compressed powers of ten of Dougall Johnson, and the SSE support of Tobias Schlüter: ported to C++11.
- simdjson (Apache-2.0 or MIT) by Daniel Lemire, Geoff Langdale, John Keiser,
and contributors: the "lookup4" UTF-8 check and its tables (J. Keiser and D. Lemire, "Validating UTF-8 In Less Than
One Instruction Per Byte", 2021), in
json.hpp's lexer and injson_view.hpp. - yyjson (MIT) by YaoYuan: table-driven
\udecoding and the unrolled checks at fixed offsets injson_view.hpp's parser.
Ideas we took. From these we took the idea, not their code, in the order they appear above:
- serde_json (MIT or Apache-2.0): deriving positions from the offset only on error.
- Sean Eron Anderson's Bit Twiddling Hacks (snippets in the public domain): the zero-byte test behind every word-at-a-time scan.
- Glaze (MIT): skipping indentation in 8-byte steps (
skip_matching_ws). - SQLite (public domain): writing short strings with a table loop, as its
jsonAppendString()does. - orjson (Apache-2.0 or MIT; its escape table MPL-2.0) and
sonic-rs (Apache-2.0), from which orjson adapted it: escape sequences as
fixed-size table rows (
QUOTE_TAB). - simdjson's tape and yyjson's 16-byte immutable document: a flat index in document order instead of a tree.
- Boost.JSON (BSL-1.0): switching to a hash table for large objects.
- RapidJSON (MIT): recursion as the default, iterative parsing as an option.
I thank the people who sent or shaped pull requests:
- @gregmarr reviewed most of them and suggested the sized-sentinel fast path, the peek
design for exact stream positions, one SIMD switch for the whole library, and the vector check of
json_view's strings on edit; - @veselink1 and @michaelsam94 wrote the allocator and exception-safety fixes for the destructor that 3.13.0 took over; the non-recursive destructor itself goes back to a pull request by @nickaein in 2019;
- @commit-msuyog gave the 128-bit multiplication behind Eisel-Lemire MSVC intrinsics;
- @ayush-singh-0601 started the fix for deep values in the binary writers;
- @avionicharshit-byte made
diff()linear for shrinking arrays; - @mvanslobbe sped up the parser's separator check;
- @alexprabhat99 removed the temporary values behind CBOR and MessagePack object keys.
twitter.json, citm_catalog.json and canada.json come from Milo Yip's
nativejson-benchmark (MIT).
Appendix
A. Test files
| file | size | what it is | what makes it hard |
|---|---|---|---|
twitter.json |
632 KB | 100 tweets from Twitter's search API, with their users | strings: 58% of the bytes are keys and strings, 15% of the bytes are non-ASCII (almost all of it Japanese text), and 13,345 keys |
citm_catalog.json |
1.7 MB | a concert hall's catalog of events, performances and seat categories, in French | structure: 21,388 small objects and arrays, 14,392 integers, keys that average 8 bytes, and 71% whitespace from indentation |
canada.json |
2.3 MB | the outline of Canada in GeoJSON | numbers: 111,080 floating-point coordinates, most with 16 or 17 significant digits, in 56,045 arrays, and almost no whitespace |
jeopardy.json |
56 MB | 216,930 Jeopardy! questions as one array of objects | size: 1.5 million short strings in objects of 7 members; far larger than the CPU caches |
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
The rows of the first chart:
| workload | 3.12.0 | 3.13.0 | speedup | Intel |
|---|---|---|---|---|
json::parse() of twitter.json |
3.30 ms | 1.05 ms | 3.1× | 2.6× |
json::parse() of citm_catalog.json |
7.56 ms | 1.78 ms | 4.3× | 3.5× |
json::parse() of canada.json |
15.5 ms | 4.63 ms | 3.4× | 6.1× |
json::parse() of canada.json, indented by 4 spaces |
35.0 ms | 5.37 ms | 6.5× | 8.0× |
ordered_json::parse() of citm_catalog.json |
11.5 ms | 1.68 ms | 6.9× | 6.6× |
json::parse() of twitter.json from an std::ifstream |
3.43 ms | 1.37 ms | 2.5× | 2.3× |
json::parse() of citm_catalog.json from a FILE* |
36.0 ms | 7.58 ms | 4.7× | 5.7× |
json::accept() of citm_catalog.json |
6.53 ms | 0.923 ms | 7.1× | 8.4× |
dump() of twitter.json |
1.44 ms | 0.214 ms | 6.7× | 5.7× |
dump() of canada.json |
6.95 ms | 2.34 ms | 3.0× | 3.3× |
dump(4) of citm_catalog.json |
1.99 ms | 0.423 ms | 4.7× | 4.1× |
to_cbor() of twitter.json |
0.659 ms | 0.143 ms | 4.6× | 6.7× |
from_cbor() of twitter.json from memory |
3.23 ms | 0.953 ms | 3.4× | 2.8× |
from_cbor() of twitter.json from a FILE* |
10.4 ms | 1.57 ms | 6.6× | 6.5× |
diff() of canada.json with every eighth scalar changed |
39.8 ms | 3.80 ms | 10.5× | 11.2× |
iterating over every value of canada.json |
0.897 ms | 0.328 ms | 2.7× | 2.0× |
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).
??? info "All 182 benchmarks of the suite"
| benchmark | 3.12.0 | 3.13.0 | speedup | Intel |
| --- | --: | --: | --: | --: |
| `Accept/canada` | 12.9 ms | 2.35 ms | 5.51× | 13× |
| `Accept/citm_catalog` | 6.53 ms | 923 µs | 7.07× | 8.45× |
| `Accept/floats` | 116 ms | 22.7 ms | 5.10× | 12.2× |
| `Accept/jeopardy` | 221 ms | 59.3 ms | 3.72× | 6.64× |
| `Accept/signed_ints` | 117 ms | 28.3 ms | 4.15× | 4.99× |
| `Accept/small_signed_ints` | 57.5 ms | 18 ms | 3.19× | 3.90× |
| `Accept/twitter` | 2.51 ms | 484 µs | 5.20× | 6.89× |
| `Accept/unsigned_ints` | 116 ms | 29.7 ms | 3.91× | 4.95× |
| `BinaryToCbor/1024` | 298 ns | 107 ns | 2.79× | 3.67× |
| `BinaryToCbor/128` | 249 ns | 117 ns | 2.13× | 2.27× |
| `BinaryToCbor/16384` | 952 ns | 414 ns | 2.30× | 3.46× |
| `BinaryToCbor/16` | 245 ns | 49.6 ns | 4.93× | 6.33× |
| `BinaryToCbor/2048` | 328 ns | 125 ns | 2.62× | 3.74× |
| `BinaryToCbor/256` | 265 ns | 90.9 ns | 2.91× | 3.33× |
| `BinaryToCbor/32768` | 1.54 µs | 803 ns | 1.92× | 2.11× |
| `BinaryToCbor/32` | 252 ns | 50.4 ns | 4.99× | 6.24× |
| `BinaryToCbor/4096` | 407 ns | 177 ns | 2.29× | 3.48× |
| `BinaryToCbor/512` | 290 ns | 102 ns | 2.84× | 3.02× |
| `BinaryToCbor/64` | 255 ns | 84.6 ns | 3.01× | 3.78× |
| `BinaryToCbor/8192` | 514 ns | 256 ns | 2.01× | 3.30× |
| `BinaryToCbor/8` | 248 ns | 49.4 ns | 5.01× | 6.18× |
| `Copy/canada` | 3.78 ms | 2.93 ms | 1.29× | 1.17× |
| `Copy/citm_catalog` | 1.7 ms | 1.51 ms | 1.13× | 1.12× |
| `Copy/twitter` | 840 µs | 767 µs | 1.09× | 1.14× |
| `CopyAssign/canada` | 3.86 ms | 2.94 ms | 1.31× | 1.19× |
| `CopyAssign/citm_catalog` | 1.77 ms | 1.59 ms | 1.11× | 1.12× |
| `CopyAssign/twitter` | 885 µs | 791 µs | 1.12× | 1.07× |
| `CopyOnly/canada` | 1.6 ms | 1.58 ms | 1.02× | 1.00× |
| `CopyOnly/citm_catalog` | 786 µs | 817 µs | 0.96× | 0.93× |
| `CopyOnly/twitter` | 424 µs | 421 µs | 1.01× | 1.02× |
| `CopyRing/canada` | 3.86 ms | 3 ms | 1.29× | 1.20× |
| `CopyRing/citm_catalog` | 1.84 ms | 1.61 ms | 1.15× | 1.20× |
| `CopyRing/twitter` | 952 µs | 818 µs | 1.16× | 1.15× |
| `DestroyOnly/canada` | 2.22 ms | 1.4 ms | 1.58× | 1.32× |
| `DestroyOnly/citm_catalog` | 926 µs | 711 µs | 1.30× | 1.20× |
| `DestroyOnly/twitter` | 416 µs | 336 µs | 1.24× | 1.15× |
| `Diff/canada` | 39.8 ms | 3.8 ms | 10.5× | 11.2× |
| `Diff/citm_catalog` | 7.19 ms | 1.12 ms | 6.40× | 7.78× |
| `Diff/twitter` | 5.11 ms | 661 µs | 7.73× | 8.79× |
| `Dump/canada / -` | 6.95 ms | 2.34 ms | 2.98× | 3.34× |
| `Dump/canada / 4` | 9.1 ms | 3.1 ms | 2.93× | 2.82× |
| `Dump/citm_catalog / -` | 1.42 ms | 296 µs | 4.81× | 3.93× |
| `Dump/citm_catalog / 4` | 1.99 ms | 423 µs | 4.70× | 4.09× |
| `Dump/floats / -` | 64.3 ms | 23.8 ms | 2.71× | 2.50× |
| `Dump/floats / 4` | 72.6 ms | 24.6 ms | 2.95× | 2.71× |
| `Dump/jeopardy / -` | 207 ms | 31.7 ms | 6.51× | 3.75× |
| `Dump/jeopardy / 4` | 228 ms | 35.4 ms | 6.45× | 2.61× |
| `Dump/signed_ints / -` | 24.1 ms | 10.7 ms | 2.24× | 1.44× |
| `Dump/signed_ints / 4` | 32.5 ms | 11.3 ms | 2.87× | 2.01× |
| `Dump/small_signed_ints / -` | 18.1 ms | 8.12 ms | 2.23× | 1.81× |
| `Dump/small_signed_ints / 4` | 25.5 ms | 8.81 ms | 2.90× | 2.21× |
| `Dump/twitter / -` | 1.44 ms | 214 µs | 6.70× | 5.75× |
| `Dump/twitter / 4` | 1.63 ms | 253 µs | 6.45× | 5.18× |
| `Dump/unsigned_ints / -` | 24 ms | 8.12 ms | 2.95× | 1.58× |
| `Dump/unsigned_ints / 4` | 32.3 ms | 8.59 ms | 3.76× | 2.30× |
| `Equal/canada` | 474 µs | 349 µs | 1.36× | 1.38× |
| `Equal/citm_catalog` | 280 µs | 263 µs | 1.06× | 0.95× |
| `Equal/twitter` | 146 µs | 131 µs | 1.11× | 1.02× |
| `Flatten/canada` | 29.4 ms | 11.1 ms | 2.65× | 2.68× |
| `Flatten/citm_catalog` | 6.72 ms | 3.07 ms | 2.19× | 2.45× |
| `Flatten/twitter` | 3.66 ms | 2.11 ms | 1.74× | 2.19× |
| `FlattenOrdered/citm_catalog` | 262 ms | 1.43 ms | 184× | 185× |
| `FlattenOrdered/twitter` | 51.3 ms | 890 µs | 57.6× | 46.1× |
| `FromBinaryBuffer/bjdata / canada` | 8.82 ms | 4.17 ms | 2.12× | 2.02× |
| `FromBinaryBuffer/bjdata / twitter` | 3.32 ms | 976 µs | 3.40× | 2.87× |
| `FromBinaryBuffer/bjdata_optimized / canada` | 9.24 ms | 2.48 ms | 3.73× | 3.51× |
| `FromBinaryBuffer/bjdata_optimized / twitter` | 3.31 ms | 986 µs | 3.35× | 2.86× |
| `FromBinaryBuffer/bson / canada` | 10 ms | 4.32 ms | 2.33× | 2.20× |
| `FromBinaryBuffer/bson / citm_catalog` | 4.08 ms | 1.61 ms | 2.54× | 2.59× |
| `FromBinaryBuffer/bson / twitter` | 3.26 ms | 937 µs | 3.48× | 3.08× |
| `FromBinaryBuffer/cbor / canada` | 8.9 ms | 2.12 ms | 4.21× | 3.86× |
| `FromBinaryBuffer/cbor / citm_catalog` | 3.94 ms | 1.45 ms | 2.71× | 2.81× |
| `FromBinaryBuffer/cbor / floats` | 30.5 ms | 5.48 ms | 5.56× | 3.64× |
| `FromBinaryBuffer/cbor / jeopardy` | 373 ms | 115 ms | 3.25× | 3.43× |
| `FromBinaryBuffer/cbor / signed_ints` | 36.7 ms | 9.89 ms | 3.71× | 2.37× |
| `FromBinaryBuffer/cbor / twitter` | 3.23 ms | 953 µs | 3.39× | 2.83× |
| `FromBinaryBuffer/msgpack / canada` | 8.86 ms | 2.12 ms | 4.17× | 3.92× |
| `FromBinaryBuffer/msgpack / citm_catalog` | 3.85 ms | 1.45 ms | 2.65× | 2.94× |
| `FromBinaryBuffer/msgpack / jeopardy` | 373 ms | 111 ms | 3.36× | 3.51× |
| `FromBinaryBuffer/msgpack / twitter` | 3.24 ms | 955 µs | 3.39× | 3.02× |
| `FromBinaryBuffer/ubjson / canada` | 8.84 ms | 4.2 ms | 2.10× | 2.13× |
| `FromBinaryBuffer/ubjson / citm_catalog` | 4.07 ms | 1.79 ms | 2.28× | 2.44× |
| `FromBinaryBuffer/ubjson / jeopardy` | 379 ms | 117 ms | 3.24× | 3.40× |
| `FromBinaryBuffer/ubjson / twitter` | 3.3 ms | 981 µs | 3.36× | 2.90× |
| `FromBinaryBuffer/ubjson_optimized / canada` | 8.89 ms | 2.43 ms | 3.66× | 3.67× |
| `FromBinaryBuffer/ubjson_optimized / twitter` | 3.3 ms | 975 µs | 3.38× | 2.88× |
| `FromBinaryFile/bjdata / twitter` | 10.9 ms | 1.95 ms | 5.61× | 5.89× |
| `FromBinaryFile/bson / twitter` | 10.7 ms | 1.85 ms | 5.80× | 6.21× |
| `FromBinaryFile/cbor / canada` | 14.9 ms | 4.49 ms | 3.32× | 3.68× |
| `FromBinaryFile/cbor / twitter` | 10.4 ms | 1.57 ms | 6.59× | 6.46× |
| `FromBinaryFile/ubjson / canada` | 14.3 ms | 6.43 ms | 2.23× | 2.50× |
| `FromBinaryFile/ubjson / twitter` | 10.9 ms | 1.97 ms | 5.54× | 5.90× |
| `FromBinaryShape/containers / bjdata` | 14.7 ms | 7.23 ms | 2.03× | 2.05× |
| `FromBinaryShape/containers / bson` | 17.6 ms | 7.89 ms | 2.23× | 2.20× |
| `FromBinaryShape/containers / cbor` | 14.6 ms | 4.16 ms | 3.52× | 3.49× |
| `FromBinaryShape/containers / msgpack` | 14.6 ms | 3.89 ms | 3.75× | 3.52× |
| `FromBinaryShape/containers / ubjson_optimized` | 15.5 ms | 4.56 ms | 3.40× | 3.34× |
| `FromBinaryShape/containers / ubjson` | 15 ms | 7.24 ms | 2.07× | 2.03× |
| `FromBinaryShape/nested / bjdata` | 96.8 µs | 38.3 µs | 2.53× | 2.50× |
| `FromBinaryShape/nested / bson` | 101 µs | 42.6 µs | 2.38× | 2.42× |
| `FromBinaryShape/nested / cbor` | 94.9 µs | 35.8 µs | 2.65× | 2.57× |
| `FromBinaryShape/nested / msgpack` | 92.6 µs | 35.1 µs | 2.64× | 2.60× |
| `FromBinaryShape/nested / ubjson` | 96.1 µs | 38.3 µs | 2.51× | 2.50× |
| `FromBinaryShape/scalars / bjdata` | 25.3 ms | 11.6 ms | 2.18× | 2.22× |
| `FromBinaryShape/scalars / cbor` | 26.2 ms | 5.89 ms | 4.45× | 3.56× |
| `FromBinaryShape/scalars / msgpack` | 25.1 ms | 5.65 ms | 4.44× | 3.69× |
| `FromBinaryShape/scalars / ubjson` | 25.3 ms | 11.6 ms | 2.18× | 2.03× |
| `FromCborChunkedString/10000 chunks` | 91.8 µs | 82.9 µs | 1.11× | 1.09× |
| `FromMsgpack/canada` | 14.9 ms | 4.56 ms | 3.26× | 3.63× |
| `FromMsgpack/citm_catalog` | 9.36 ms | 2.26 ms | 4.14× | 4.60× |
| `FromMsgpack/floats` | 61.3 ms | 28.7 ms | 2.14× | 2.89× |
| `FromMsgpack/jeopardy` | 1,170 ms | 190 ms | 6.14× | 6.80× |
| `FromMsgpack/signed_ints` | 63.9 ms | 32.9 ms | 1.94× | 2.44× |
| `FromMsgpack/small_signed_ints` | 64.5 ms | 34.4 ms | 1.87× | 2.33× |
| `FromMsgpack/twitter` | 10.3 ms | 1.49 ms | 6.92× | 6.76× |
| `FromMsgpack/unsigned_ints` | 57.8 ms | 28.3 ms | 2.04× | 2.77× |
| `GetArray<std::array<int, 1000>>` | 2.27 µs | 654 ns | 3.48× | 5.38× |
| `GetArray<std::deque<int>>` | 8.29 µs | 1.18 µs | 7.05× | 4.75× |
| `GetArray<std::set<int>>` | 60 µs | 42.5 µs | 1.41× | 1.30× |
| `GetArray<std::vector<int>>` | 6.79 µs | 682 ns | 9.95× | 4.10× |
| `GetMap/citm_catalog` | 3.6 ms | 2.49 ms | 1.45× | 1.48× |
| `GetVectorDouble/floats` | 6.84 ms | 656 µs | 10.4× | 2.98× |
| `GetVectorInt/signed_ints` | 8.22 ms | 3.46 ms | 2.37× | 2.02× |
| `Iterate/canada` | 897 µs | 328 µs | 2.73× | 2.02× |
| `Iterate/citm_catalog` | 305 µs | 171 µs | 1.78× | 1.53× |
| `Iterate/twitter` | 90 µs | 62 µs | 1.45× | 1.20× |
| `JsonPointerAt/citm_catalog` | 1.9 ms | 1.18 ms | 1.61× | 1.47× |
| `JsonPointerAt/twitter` | 818 µs | 612 µs | 1.34× | 1.14× |
| `JsonPointerContains/citm_catalog` | 2.82 ms | 1.27 ms | 2.22× | 2.51× |
| `JsonPointerContains/twitter` | 1.34 ms | 629 µs | 2.12× | 2.16× |
| `JsonPointerParse/citm_catalog` | 5.81 ms | 2.25 ms | 2.59× | 3.74× |
| `JsonPointerParse/twitter` | 2.48 ms | 1.02 ms | 2.43× | 3.18× |
| `JsonPointerToString/citm_catalog` | 6.79 ms | 1.94 ms | 3.50× | 4.61× |
| `JsonPointerToString/twitter` | 2.23 ms | 834 µs | 2.67× | 2.66× |
| `ParseFILE/canada` | 54.2 ms | 14.7 ms | 3.69× | 5.46× |
| `ParseFILE/citm_catalog` | 36 ms | 7.58 ms | 4.74× | 5.73× |
| `ParseFILE/twitter` | 14.1 ms | 3.45 ms | 4.08× | 4.24× |
| `ParseFile/canada` | 18.3 ms | 6.47 ms | 2.83× | 4.67× |
| `ParseFile/citm_catalog` | 8.13 ms | 2.52 ms | 3.22× | 2.98× |
| `ParseFile/floats` | 131 ms | 48.1 ms | 2.73× | 5.68× |
| `ParseFile/jeopardy` | 338 ms | 162 ms | 2.09× | 2.23× |
| `ParseFile/signed_ints` | 129 ms | 46.2 ms | 2.78× | 3.41× |
| `ParseFile/small_signed_ints` | 64.7 ms | 33.4 ms | 1.94× | 2.57× |
| `ParseFile/twitter` | 3.43 ms | 1.37 ms | 2.49× | 2.33× |
| `ParseFile/unsigned_ints` | 124 ms | 47.4 ms | 2.61× | 3.52× |
| `ParseIndented/canada / 4` | 35 ms | 5.37 ms | 6.51× | 8.02× |
| `ParseIndented/citm_catalog / 4` | 7.52 ms | 1.81 ms | 4.15× | 3.49× |
| `ParseIndented/jeopardy / 4` | 345 ms | 133 ms | 2.59× | 2.46× |
| `ParseIndented/twitter / 4` | 3.72 ms | 1.13 ms | 3.30× | 2.60× |
| `ParseStreamValues/ndjson` | 7.16 ms | 3.77 ms | 1.90× | 2.06× |
| `ParseStreamValues/numbers` | 1.93 ms | 383 µs | 5.03× | 6.06× |
| `ParseString/canada` | 15.5 ms | 4.63 ms | 3.35× | 6.13× |
| `ParseString/citm_catalog` | 7.56 ms | 1.78 ms | 4.25× | 3.46× |
| `ParseString/floats` | 122 ms | 28.3 ms | 4.30× | 8.68× |
| `ParseString/jeopardy` | 317 ms | 131 ms | 2.42× | 2.32× |
| `ParseString/signed_ints` | 123 ms | 32 ms | 3.84× | 4.29× |
| `ParseString/small_signed_ints` | 62.7 ms | 25 ms | 2.51× | 3.38× |
| `ParseString/twitter` | 3.3 ms | 1.05 ms | 3.14× | 2.60× |
| `ParseString/unsigned_ints` | 121 ms | 32.9 ms | 3.68× | 4.23× |
| `ParseStringOrdered/canada` | 15.4 ms | 4.53 ms | 3.40× | 6.21× |
| `ParseStringOrdered/citm_catalog` | 11.5 ms | 1.68 ms | 6.85× | 6.59× |
| `ParseStringOrdered/jeopardy` | 374 ms | 113 ms | 3.29× | 3.38× |
| `ParseStringOrdered/twitter` | 4.6 ms | 949 µs | 4.85× | 4.93× |
| `Patch/canada` | 10.4 ms | 3.48 ms | 2.98× | 2.46× |
| `Patch/citm_catalog` | 2.75 ms | 1.74 ms | 1.58× | 1.38× |
| `Patch/twitter` | 1.58 ms | 889 µs | 1.78× | 1.45× |
| `SaxParse/canada` | 13.1 ms | 3.27 ms | 4.01× | 8.84× |
| `SaxParse/citm_catalog` | 6.55 ms | 967 µs | 6.77× | 6.85× |
| `SaxParse/twitter` | 2.53 ms | 483 µs | 5.24× | 5.91× |
| `ToCbor/canada` | 1.41 ms | 557 µs | 2.54× | 2.27× |
| `ToCbor/citm_catalog` | 1.05 ms | 259 µs | 4.04× | 5.60× |
| `ToCbor/floats` | 9.05 ms | 1.9 ms | 4.76× | 1.94× |
| `ToCbor/jeopardy` | 73.9 ms | 22.1 ms | 3.35× | 2.87× |
| `ToCbor/signed_ints` | 11.5 ms | 2.83 ms | 4.08× | 1.83× |
| `ToCbor/small_signed_ints` | 10.4 ms | 2.48 ms | 4.19× | 2.78× |
| `ToCbor/twitter` | 659 µs | 143 µs | 4.59× | 6.67× |
| `ToCbor/unsigned_ints` | 8.18 ms | 2.82 ms | 2.90× | 1.48× |
| `ToJsonStrings/copy` | 6.12 ms | 5.65 ms | 1.08× | 1.05× |
| `ToJsonStrings/move` | 6.13 ms | 3.13 ms | 1.96× | 1.78× |
| `Unflatten/canada` | 42.5 ms | 11.1 ms | 3.83× | 4.32× |
| `Unflatten/citm_catalog` | 10.2 ms | 3.62 ms | 2.81× | 3.32× |
| `Unflatten/twitter` | 4.75 ms | 2.21 ms | 2.15× | 2.42× |
C. The timeline
The other four workloads we followed:
accept()oftwitter.json: the lexer alone, sinceaccept()only validates and builds nothing;parse()oftwitter.jsonfrom anstd::ifstream: reading from a stream instead of a string;dump()ofcanada.json: writing floating-point numbers;to_cbor()oftwitter.json: writing a binary format.
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, #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 |
|---|---|---|---|---|
parse() citm_catalog.json |
4 | #5234 | the token buffer is filled only for error messages | −35% |
| 1 | #5283 | fast paths for contiguous input | −19% | |
| 4 | #5592 | check for the expected : or , before the lexer's token switch |
−7% | |
| 4 | #5809 | positions derived only on error | −12% | |
| 2 | #5810 | indentation skipped eight bytes at a time | −12% | |
| 4 | #5882 | arrays allocated once, at their final size | −6% | |
parse() canada.json |
4 | #5234 | the token buffer is filled only for error messages | −10% |
| 1 | #5283 | fast paths for contiguous input | −24% | |
| 3 | #5617 | Eisel-Lemire instead of strtod |
−9% | |
| 3 | #5738 | own float parser, string scan, \u table |
−14% | |
| 3 | #5851 | digits accumulated while validating, eight at a time | −12% | |
| 4 | #5882 | arrays allocated once, at their final size | −15% | |
accept() twitter.json |
4 | #5234 | the token buffer is filled only for error messages | −18% |
| 1 | #5283 | fast paths for contiguous input | −53% | |
| 4 | #5592 | check for the expected : or , before the lexer's token switch |
−11% | |
| 4 | #5809 | positions derived only on error | −23% | |
| 2 | #5810 | indentation skipped eight bytes at a time | −9% | |
| 2 | #5853 | strings scanned 16 bytes at a time with SSE2 or NEON | −11% | |
parse() twitter.json, std::ifstream |
1 | #5828 | scanning the stream's get area in bulk | −48% |
| 1 | #5884 | inline get-area path, binary reads | −5% | |
dump() twitter.json |
6 | #5285 | a write buffer and bulk copies of strings | −70% |
| 2 | #5738 | own float parser, string scan, \u table |
−19% | |
| 2 | #5878 | short strings checked with a table | −6% | |
| 2 | #5883 | strings of 16–32 bytes as two overlapping vectors | −34% | |
dump() canada.json |
6 | #5285 | a write buffer and bulk copies of strings | −8% |
| 3 | #5634 | Żmij instead of Grisu2 | −65% | |
from_cbor() twitter.json |
1 | #5233 | binary strings read in bulk | −32% |
| 4 | #5501 | the result moved instead of copied | −33% | |
| U | #5185, #5531 | UTF-8 validation of every text string | +37% | |
| U | #5746 | the validation made an option (#5741) | −27% | |
to_cbor() twitter.json |
4 | #5328 | no temporary basic_json per object key (by @alexprabhat99) |
−55% |
| 6 | #5824 | a cursor instead of vector::insert() |
−38% | |
| 6 | #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).
D. Algorithmic fixes
| operation | 3.12.0 | 3.13.0 | speedup | cause | PR |
|---|---|---|---|---|---|
diff() of an array of 64,000 numbers against an empty array |
4,237 ms | 10.8 ms | 394× | each remove was inserted at the front of the patch |
#5461 |
diff() of an array of 20,000 objects against every second one of them |
122 ms | 4.13 ms | 29× | the same | #5461 |
parse() with a callback that rejects half of 32,000 objects |
1,398 ms | 12.1 ms | 116× | each rejected value was searched for from the start of its parent | #5457 |
< on two equal values nested 24 levels deep (C++11 to C++17) |
64.4 ms | 0.002 ms | exponential | std::vector's < asks a < b and b < a, and both recurse |
#5390 |
< on citm_catalog.json and a copy, 10× (C++11 to C++17) |
284 ms | 3.94 ms | 72× | the same | #5390 |
flatten() of citm_catalog.json as ordered_json |
271 ms | 1.45 ms | 187× | every new key was looked up linearly | #5834 |
to_bson() of a value nested 200 levels deep, 100× |
20.3 ms | 0.352 ms | 58× | each document's size was computed by walking everything below it again | #5553, #5979 |
100,000 × push_back() on an ordered_json with JSON_DIAGNOSTICS |
1,589 ms | 5.32 ms | 299× | every insert re-pointed the parent pointers of all elements | #5832 |
10,000 × at(i) on an ordered_json array with JSON_DIAGNOSTICS |
45.9 ms | 0.088 ms | 522× | the same | #5832 |
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,
whose README lists every variant, the pinned versions and checksums of every library, and the build commands. The
libraries: yyjson 0.13.0, simdjson 5.1.1,
sonic-cpp 1.0.2, RapidJSON (master,
24b5e7a, as its last release is from 2016), Glaze 9.1.0,
Boost.JSON 1.92, and in Rust sonic-rs
0.5.10, simd-json 0.19.0 and serde_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
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).
Conformance. Every validate way ran the 318 files of 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 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.
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.
??? info "Times in µs on the Apple M1 Max"
**validate**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | accept | 170 | 313 | 886 | 20,873 | 0.9 |
| yyjson | insitu (copy untimed) | 172 | 451 | 1,457 | 30,240 | 0.9 |
| simdjson | DOM | 212 | 452 | 1,888 | 28,843 | 1.1 |
| sonic-rs (Rust) | Value | 326 | 681 | 1,939 | 31,450 | 1.4 |
| Glaze | validate_json | 331 | 862 | 1,326 | 45,192 | 1.4 |
| simd-json (Rust) | tape (copy) | 289 | 698 | 2,278 | 38,874 | 1.7 |
| sonic-cpp | Document [U] | – (U 382) | – (U 985) | – (U 2,578) | – (U 41,617) | – (U 1.6) |
| serde_json (Rust) | IgnoredAny [U], from_utf8 + IgnoredAny | 452 (U 354) | 1,103 (U 1,047) | 1,556 (U 1,451) | 46,517 (U 44,984) | 2.0 (U 1.7) |
| Boost.JSON | SAX, SAX (numbers: none) | 535 | 994 | 1,467 | 53,939 | 2.3 |
| nlohmann/json `json` | accept | 451 | 888 | 2,405 | 55,286 | 2.4 |
| RapidJSON | SAX (default precision), SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,307 (U 686) | 1,397 (U 1,032) | 1,982 | 134,679 (U 63,589) | 6.0 (U 2.6) |
**sum**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse | 224 | 485 | 1,816 | 28,987 | 1.2 |
| yyjson | insitu (copy untimed) | 219 | 551 | 1,906 | 37,512 | 1.1 |
| simdjson | DOM, On-Demand | 249 | 571 | 2,374 | 35,111 | 1.3 |
| sonic-rs (Rust) | visitor | 364 | 778 | 2,703 | 41,361 | 1.7 |
| simd-json (Rust) | visitor (copy) | 354 | 866 | 2,717 | 47,518 | 2.0 |
| sonic-cpp | Document [U] | – (U 428) | – (U 1,087) | – (U 2,993) | – (U 48,688) | – (U 1.9) |
| nlohmann/json `json` | sax | 465 | 925 | 3,072 | 64,089 | 2.4 |
| Boost.JSON | SAX | 535 | 1,010 | 3,067 | 53,704 | 2.3 |
| serde_json (Rust) | visitor | 652 | 1,242 | 4,772 | 66,568 | 2.5 |
| Glaze | generic (double) [P], generic (double, strict), lazy_json [P] | 968 | 2,049 | 6,331 | 99,763 | 4.9 (P 3.5) |
| RapidJSON | SAX, SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed) (no encoding check) [U] | 1,329 (U 735) | 1,455 (U 1,136) | 6,453 | 136,103 (U 69,604) | 6.1 (U 2.9) |
**select**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse | 184 | 363 | 956 | 24,991 | 1.0 |
| yyjson | insitu (copy untimed) | 178 | 456 | 1,461 | 32,514 | 1.0 |
| simdjson | DOM, On-Demand (key selectors) [P], On-Demand [P], On-Demand structs [P] | 196 (P 127) | 450 (P 354) | 1,886 (P 566) | 30,504 (P 19,562) | 1.2 (P 0.8) |
| Glaze | structs (validate_skipped), structs [P] | 311 (P 241) | 854 (P 255) | 1,316 (P 313) | 46,924 (P 37,578) | 1.4 (P 1.2) |
| sonic-rs (Rust) | Value | 333 | 691 | 1,936 | 40,660 | 1.7 |
| sonic-cpp | Document [U] | – (U 385) | – (U 990) | – (U 2,586) | – (U 45,858) | – (U 1.7) |
| simd-json (Rust) | structs (copy) | 364 | 914 | 2,657 | 57,697 | 2.1 |
| serde_json (Rust) | from_utf8 + structs, structs [U] | 480 (U 410) | 1,125 (U 1,084) | 1,560 (U 1,455) | 62,378 | 2.3 (U 1.9) |
| Boost.JSON | parse, parse_into structs | 617 | 1,205 | 3,650 | 67,255 | 2.7 |
| RapidJSON | Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,304 (U 679) | 1,554 (U 1,126) | 6,776 | 141,509 (U 65,625) | 6.5 (U 2.6) |
| nlohmann/json `json` | parse, structs (from_json) | 1,360 | 2,416 | 6,047 | 179,533 | 7.6 |
**edit**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | editable, editable (source numbers) [N] | 291 | 560 | 2,765 (N 1,484) | 58,057 | 1.9 |
| yyjson | insitu (copy untimed) + mut copy | 427 | 882 | 3,642 | 84,239 | 2.4 |
| sonic-cpp | Document [U] | – (U 486) | – (U 1,224) | – (U 5,638) | – (U 62,688) | – (U 2.6) |
| sonic-rs (Rust) | Value | 634 | 1,257 | 4,396 | 140,936 | 3.8 |
| simd-json (Rust) | borrowed (copy) | 948 | 2,102 | 7,654 | 110,693 | 5.4 |
| Boost.JSON | parse | 1,368 | 1,992 | 6,573 | 155,237 | 6.6 |
| nlohmann/json `json` | ordered_json, parse | 1,273 | 2,344 | 8,334 | 203,232 | 7.1 |
| Glaze | generic (strict), generic [P] | 1,539 | 3,235 | 11,083 | 178,333 | 8.3 |
| RapidJSON | Document, Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,922 (U 1,344) | 2,388 (U 1,964) | 13,836 | 221,270 (U 144,741) | 10.3 (U 6.5) |
| serde_json (Rust) | Value | 2,009 | 3,316 | 9,666 | 251,931 | 9.6 |
**minify**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse + dump, parse + dump (source numbers) [N] | 259 | 493 | 2,582 (N 1,360) | 37,882 | 1.4 |
| yyjson | insitu (copy untimed) + write, read (raw numbers) + write [N] | 279 | 625 | 3,196 (N 2,550) | 50,821 | 1.6 |
| simdjson | DOM + to_string, minify [P] | 423 (P 62.3) | 848 (P 171) | 5,120 (P 227) | 61,059 (P 5,762) | 2.4 (P 0.3) |
| sonic-rs (Rust) | Value | 535 | 1,134 | 4,377 | 64,832 | 3.0 |
| sonic-cpp | Document + Serialize [U] | – (U 500) | – (U 1,220) | – (U 5,676) | – (U 61,236) | – (U 2.5) |
| simd-json (Rust) | borrowed (copy) | 952 | 2,111 | 7,658 | 108,130 | 5.5 |
| Boost.JSON | parse + serialize | 1,393 | 1,986 | 6,542 | 154,677 | 6.7 |
| RapidJSON | Document + Writer, SAX to Writer, SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] | 1,525 (U 1,058) | 1,808 (U 1,485) | 14,460 (U N 10,908) | 174,558 (U 123,119) | 8.1 (U 5.0) |
| nlohmann/json `json` | parse + dump | 1,600 | 2,706 | 8,389 | 197,745 | 8.8 |
| Glaze | generic (strict), minify_json [P] | 1,505 (P 281) | 3,225 (P 729) | 11,092 (P 1,722) | 168,855 (P 39,976) | 8.1 (P 1.3) |
| serde_json (Rust) | Value | 2,004 | 3,290 | 9,792 | 227,369 | 9.5 |
**write**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | dump, dump (source numbers) [N] | 74.3 | 134 | 1,621 (N 401) | 14,757 | 0.5 |
| yyjson | write | 103 | 169 | 1,731 | 20,087 | 0.7 |
| sonic-cpp | Serialize | 108 | 230 | 3,093 | 19,651 | 0.8 |
| nlohmann/json `json` | dump | 223 | 287 | 2,276 | 31,825 | 1.2 |
| simdjson | to_string | 199 | 385 | 3,241 | 32,208 | 1.3 |
| Glaze | generic, generic (strict) | 216 | 469 | 1,720 | 33,934 | 1.3 |
| sonic-rs (Rust) | Value | 212 | 446 | 2,419 | 33,366 | 1.5 |
| serde_json (Rust) | Value | 322 | 490 | 2,286 | 49,967 | 2.0 |
| simd-json (Rust) | borrowed | 355 | 478 | 2,728 | 47,412 | 1.8 |
| Boost.JSON | serialize | 507 | 508 | 2,923 | 62,583 | 2.9 |
| RapidJSON | Writer | 603 | 830 | 6,995 | 81,150 | 3.9 |
??? info "Times in µs on x86-64 with GCC 16.2"
**validate**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | accept | 258 | 513 | 934 | 34,514 | 1.2 |
| yyjson | insitu (copy untimed), read (raw numbers) | 352 | 837 | 2,199 | 93,043 | 1.5 |
| sonic-rs (Rust) | IgnoredAny | 501 | 970 | 2,249 | 54,369 | 1.8 |
| Boost.JSON | SAX, SAX (numbers: none) | 690 | 917 | 1,676 | 62,781 | 2.9 |
| simdjson | DOM | 301 | 695 | 3,337 | 120,982 | 1.7 |
| sonic-cpp | Document [U] | – (U 403) | – (U 932) | – (U 3,349) | – (U 98,511) | – (U 1.7) |
| serde_json (Rust) | IgnoredAny [U], from_utf8 + IgnoredAny | 624 (U 484) | 1,440 (U 1,370) | 2,406 (U 2,314) | 64,720 (U 58,247) | 2.4 (U 1.9) |
| Glaze | validate_json | 712 | 1,983 | 2,625 | 76,759 | 2.5 |
| nlohmann/json `json` | accept | 683 | 1,419 | 4,559 | 74,331 | 3.1 |
| simd-json (Rust) | tape (copy) | 567 | 1,430 | 4,359 | 207,551 | 2.9 |
| RapidJSON | SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,166 (U 1,012) | 1,553 (U 1,434) | 3,875 | 114,704 (U 102,635) | 4.8 (U 3.2) |
**sum**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| simdjson | On-Demand | 348 | 805 | 3,452 | 92,769 | 1.9 |
| **nlohmann/json `json_document`** | parse | 398 | 972 | 3,501 | 89,756 | 2.0 |
| yyjson | insitu (copy untimed) | 415 | 986 | 3,818 | 97,586 | 1.9 |
| sonic-rs (Rust) | visitor | 501 | 1,123 | 4,397 | 59,583 | 2.5 |
| sonic-cpp | Document [U] | – (U 456) | – (U 1,115) | – (U 3,980) | – (U 105,081) | – (U 2.0) |
| Boost.JSON | SAX | 699 | 957 | 5,835 | 59,656 | 3.0 |
| nlohmann/json `json` | sax | 814 | 1,666 | 5,690 | 97,414 | 4.0 |
| simd-json (Rust) | visitor (copy) | 681 | 1,650 | 5,083 | 210,185 | 3.5 |
| RapidJSON | SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,221 (U 1,063) | 1,717 (U 1,635) | 9,435 | 112,158 (U 105,845) | 5.1 (U 3.5) |
| serde_json (Rust) | visitor | 1,311 | 2,288 | 7,127 | 129,497 | 4.6 |
| Glaze | generic (double) [P], generic (double, strict), lazy_json [P] | 2,483 | 4,413 | 10,532 | 231,505 | 8.5 (P 6.4) |
**select**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse, structs (from_json) | 333 | 828 | 1,368 | 89,876 | 1.7 |
| yyjson | insitu (copy untimed), read (raw numbers) | 366 | 840 | 2,204 | 96,172 | 1.7 |
| sonic-rs (Rust) | structs | 504 | 1,033 | 2,328 | 90,486 | 2.0 |
| simdjson | DOM, On-Demand (key selectors) [P], On-Demand [P] | 313 (P 180) | 699 (P 496) | 3,325 (P 690) | 122,556 (P 48,272) | 1.9 (P 1.1) |
| sonic-cpp | Document [U] | – (U 407) | – (U 948) | – (U 3,464) | – (U 110,885) | – (U 1.8) |
| serde_json (Rust) | from_utf8 + structs, structs [U] | 701 | 1,532 | 2,529 (U 2,402) | 107,316 | 3.0 |
| Glaze | structs (validate_skipped), structs [P] | 606 (P 344) | 2,476 (P 402) | 2,557 (P 417) | 89,957 (P 67,467) | 2.1 (P 1.5) |
| simd-json (Rust) | structs (copy) | 679 | 1,731 | 5,144 | 239,164 | 3.5 |
| Boost.JSON | parse, parse_into structs | 887 | 1,270 | 7,292 | 95,866 | 3.7 |
| RapidJSON | Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,180 (U 1,022) | 1,693 | 10,390 | 116,394 (U 108,290) | 4.9 (U 3.4) |
| nlohmann/json `json` | parse, structs (from_json) | 3,360 | 5,268 | 12,006 | 410,221 | 17.7 |
**edit**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | editable, editable (source numbers) [N] | 536 | 1,162 | 5,567 (N 2,188) | 192,323 | 3.0 |
| sonic-cpp | Document [U] | – (U 620) | – (U 1,438) | – (U 8,495) | – (U 186,078) | – (U 3.4) |
| yyjson | insitu (copy untimed) + mut copy | 746 | 1,526 | 7,479 | 275,025 | 3.6 |
| sonic-rs (Rust) | Value | 1,197 | 2,389 | 7,744 | 390,952 | 6.9 |
| Boost.JSON | parse | 1,767 | 2,493 | 12,767 | 331,310 | 8.5 |
| RapidJSON | Document (string stream), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,865 (U 1,737) | 2,616 | 18,812 | 245,724 (U 234,669) | 8.7 (U 7.2) |
| simd-json (Rust) | borrowed (copy), owned (copy) | 1,862 | 4,194 | 14,896 | 391,369 | 9.0 |
| nlohmann/json `json` | ordered_json | 2,832 | 4,970 | 15,806 | 647,704 | 13.1 |
| Glaze | generic (strict), generic [P] | 3,389 | 6,002 | 21,598 | 607,767 | 14.8 |
| serde_json (Rust) | Value | 4,042 | 6,586 | 16,631 | 632,766 | 17.6 |
**minify**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse + dump, parse + dump (source numbers) [N] | 518 (N 496) | 1,045 | 5,240 (N 2,196) | 143,737 | 2.5 (N 2.4) |
| yyjson | insitu (copy untimed) + write, insitu (copy) + write, read (raw numbers) + write [N] | 626 | 1,160 | 6,701 (N 2,901) | 156,605 | 2.8 |
| sonic-cpp | Document + Serialize [U] | – (U 675) | – (U 1,417) | – (U 8,958) | – (U 164,630) | – (U 3.3) |
| simdjson | DOM + to_string, minify [P] | 714 (P 99.9) | 1,318 (P 278) | 9,135 (P 312) | 269,597 (P 42,644) | 3.7 (P 0.5) |
| sonic-rs (Rust) | Value | 1,000 | 2,097 | 7,795 | 215,280 | 5.4 |
| Boost.JSON | parse + serialize | 1,974 | 2,525 | 12,748 | 314,299 | 8.6 |
| RapidJSON | SAX to Writer (raw numbers) [N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] | 1,949 (U 1,880) | 2,382 | 19,548 (N 14,400) | 231,979 (U 224,249) | 8.2 (U 6.2) |
| simd-json (Rust) | borrowed (copy) | 1,889 | 4,196 | 15,716 | 414,377 | 9.2 |
| Glaze | generic (strict), minify_json [P] | 3,685 (P 442) | 5,942 (P 1,292) | 22,138 (P 2,726) | 450,322 (P 97,377) | 14.0 (P 1.9) |
| serde_json (Rust) | Value | 4,012 | 6,583 | 17,306 | 509,702 | 16.5 |
| nlohmann/json `json` | parse + dump | 4,196 | 5,806 | 16,626 | 529,560 | 19.2 |
**write**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | dump, dump (source numbers) [N] | 156 (N 140) | 251 (N 230) | 3,842 (N 823) | 67,769 (N 65,376) | 0.9 (N 0.8) |
| yyjson | write | 215 | 319 | 3,360 | 66,669 | 1.3 |
| sonic-cpp | Serialize | 249 | 509 | 5,074 | 69,916 | 1.6 |
| nlohmann/json `json` | dump | 444 | 640 | 4,089 | 117,316 | 1.8 |
| simdjson | to_string | 361 | 614 | 5,609 | 158,492 | 1.8 |
| Glaze | generic | 381 | 730 | 4,071 | 131,946 | 2.0 |
| sonic-rs (Rust) | Value | 434 | 927 | 3,987 | 109,176 | 2.8 |
| Boost.JSON | serialize | 469 | 830 | 5,659 | 144,174 | 2.4 |
| simd-json (Rust) | borrowed | 582 | 800 | 5,147 | 122,623 | 2.8 |
| serde_json (Rust) | Value | 550 | 887 | 4,005 | 134,163 | 3.3 |
| RapidJSON | Writer | 678 | 979 | 9,095 | 131,408 | 3.8 |
??? info "Times in µs on x86-64 with GCC 13.4"
**validate**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | accept | 271 | 609 | 1,011 | 34,722 | 1.2 |
| sonic-rs (Rust) | IgnoredAny | 501 | 970 | 2,249 | 54,369 | 1.8 |
| yyjson | insitu (copy untimed), read (raw numbers) | 348 | 878 | 2,256 | 90,959 | 1.6 |
| Boost.JSON | SAX, SAX (numbers: none) | 710 | 969 | 1,718 | 61,476 | 3.2 |
| simdjson | DOM | 311 | 780 | 3,351 | 116,704 | 1.8 |
| Glaze | validate_json | 590 | 1,545 | 1,980 | 76,009 | 2.3 |
| sonic-cpp | Document [U] | – (U 408) | – (U 1,028) | – (U 3,622) | – (U 96,080) | – (U 1.6) |
| serde_json (Rust) | IgnoredAny [U], from_utf8 + IgnoredAny | 624 (U 484) | 1,440 (U 1,370) | 2,406 (U 2,314) | 64,720 (U 58,247) | 2.4 (U 1.9) |
| nlohmann/json `json` | accept | 677 | 1,433 | 4,584 | 73,561 | 3.3 |
| simd-json (Rust) | tape (copy) | 567 | 1,430 | 4,359 | 207,551 | 2.9 |
| RapidJSON | SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) (no encoding check) [U] | 1,552 (U 1,082) | 2,210 (U 1,570) | 4,104 | 168,747 (U 110,856) | 6.9 (U 3.7) |
**sum**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| simdjson | On-Demand | 356 | 812 | 3,435 | 96,250 | 2.0 |
| **nlohmann/json `json_document`** | parse | 392 | 946 | 3,888 | 89,044 | 2.0 |
| sonic-rs (Rust) | visitor | 501 | 1,123 | 4,397 | 59,583 | 2.5 |
| sonic-cpp | Document [U] | – (U 460) | – (U 1,210) | – (U 4,295) | – (U 107,092) | – (U 1.9) |
| yyjson | insitu (copy untimed) | 407 | 1,045 | 6,021 | 99,764 | 2.0 |
| Boost.JSON | SAX | 734 | 1,030 | 6,103 | 64,382 | 3.3 |
| nlohmann/json `json` | sax | 797 | 1,548 | 6,004 | 101,516 | 4.0 |
| simd-json (Rust) | visitor (copy) | 681 | 1,650 | 5,083 | 210,185 | 3.5 |
| serde_json (Rust) | visitor | 1,311 | 2,288 | 7,127 | 129,497 | 4.6 |
| RapidJSON | SAX (no encoding check) [U], SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,616 (U 1,130) | 2,290 (U 1,743) | 11,183 | 181,500 (U 119,706) | 7.5 (U 3.9) |
| Glaze | generic (double) [P], generic (double, strict), lazy_json [P] | 2,345 | 4,080 | 10,576 | 250,685 (P 231,181) | 9.0 (P 6.4) |
**select**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse | 332 | 765 | 1,390 | 88,202 | 1.6 |
| yyjson | insitu (copy untimed), read (raw numbers) | 361 | 880 | 2,236 | 98,637 | 1.7 |
| sonic-rs (Rust) | structs | 504 | 1,033 | 2,328 | 90,486 | 2.0 |
| simdjson | DOM, On-Demand (key selectors) [P], On-Demand [P] | 324 (P 184) | 756 (P 509) | 3,409 (P 700) | 123,214 (P 48,614) | 2.0 (P 1.2) |
| Glaze | structs (validate_skipped), structs [P] | 529 (P 342) | 1,435 (P 410) | 1,913 (P 431) | 91,253 (P 66,069) | 2.0 (P 1.5) |
| sonic-cpp | Document [U] | – (U 413) | – (U 1,043) | – (U 3,695) | – (U 108,297) | – (U 1.7) |
| serde_json (Rust) | from_utf8 + structs, structs [U] | 701 | 1,532 | 2,529 (U 2,402) | 107,316 | 3.0 |
| simd-json (Rust) | structs (copy) | 679 | 1,731 | 5,144 | 239,164 | 3.5 |
| Boost.JSON | parse, parse_into structs | 884 | 1,380 | 7,217 | 99,078 | 3.9 |
| RapidJSON | Document (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,560 (U 1,079) | 2,127 (U 1,817) | 11,547 | 176,066 (U 122,354) | 7.1 (U 3.7) |
| nlohmann/json `json` | structs (from_json) | 3,325 | 5,126 | 11,908 | 423,362 | 17.0 |
**edit**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | editable, editable (source numbers) [N] | 532 | 1,116 | 5,714 (N 2,702) | 192,363 | 3.1 |
| sonic-cpp | Document [U] | – (U 637) | – (U 1,559) | – (U 8,936) | – (U 186,232) | – (U 3.3) |
| yyjson | insitu (copy untimed) + mut copy | 761 | 1,590 | 9,860 | 271,227 | 3.7 |
| sonic-rs (Rust) | Value | 1,197 | 2,389 | 7,744 | 390,952 | 6.9 |
| Boost.JSON | parse | 1,963 | 2,936 | 13,414 | 356,244 | 9.1 |
| simd-json (Rust) | borrowed (copy), owned (copy) | 1,862 | 4,194 | 14,896 | 391,369 | 9.0 |
| RapidJSON | Document (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) | 2,333 (U 1,838) | 3,133 (U 2,836) | 20,518 | 315,892 (U 271,888) | 11.0 (U 7.7) |
| nlohmann/json `json` | ordered_json | 2,912 | 4,999 | 15,603 | 604,744 | 13.5 |
| Glaze | generic (strict), generic [P] | 3,525 | 6,551 | 23,644 | 661,941 (P 632,543) | 15.6 |
| serde_json (Rust) | Value | 4,042 | 6,586 | 16,631 | 632,766 | 17.6 |
**minify**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse + dump, parse + dump (source numbers) [N], parse_copy + dump | 483 | 1,014 | 5,459 (N 2,620) | 146,320 | 2.4 |
| yyjson | insitu (copy untimed) + write, read (raw numbers) + write [N], read + write | 592 | 1,205 | 9,008 (N 3,106) | 158,577 | 2.9 |
| sonic-cpp | Document + Serialize [U] | – (U 695) | – (U 1,541) | – (U 8,838) | – (U 168,901) | – (U 3.3) |
| simdjson | DOM + to_string, minify [P] | 742 (P 123) | 1,393 (P 337) | 8,808 (P 433) | 266,718 (P 47,040) | 3.8 (P 0.6) |
| sonic-rs (Rust) | Value | 1,000 | 2,097 | 7,795 | 215,280 | 5.4 |
| Boost.JSON | parse + serialize | 2,086 | 2,919 | 13,974 | 334,468 | 9.2 |
| simd-json (Rust) | borrowed (copy) | 1,889 | 4,196 | 15,716 | 414,377 | 9.2 |
| RapidJSON | Document + Writer, SAX to Writer, SAX to Writer (no encoding check) [U], SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] | 3,015 (U 1,978) | 3,012 (U 2,493) | 21,177 (U N 15,512) | 355,727 (U 249,235) | 13.9 (U 7.5) |
| Glaze | generic (strict), minify_json [P] | 3,780 (P 422) | 6,416 (P 1,144) | 23,765 (P 2,350) | 458,311 (P 96,405) | 14.7 (P 1.9) |
| serde_json (Rust) | Value | 4,012 | 6,583 | 17,306 | 509,702 | 16.5 |
| nlohmann/json `json` | parse + dump | 4,143 | 5,702 | 16,463 | 528,092 | 19.5 |
**write**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | dump, dump (source numbers) [N] | 150 | 260 | 3,861 (N 998) | 63,958 | 0.8 |
| yyjson | write | 204 | 327 | 3,748 | 65,989 | 1.2 |
| sonic-cpp | Serialize | 256 | 541 | 5,120 | 69,546 | 1.6 |
| Glaze | generic, generic (strict) | 360 | 660 | 3,714 | 124,598 | 1.7 |
| nlohmann/json `json` | dump | 471 | 644 | 4,344 | 114,096 | 1.9 |
| simdjson | to_string | 359 | 637 | 4,999 | 154,364 | 1.9 |
| sonic-rs (Rust) | Value | 434 | 927 | 3,987 | 109,176 | 2.8 |
| simd-json (Rust) | borrowed | 582 | 800 | 5,147 | 122,623 | 2.8 |
| Boost.JSON | serialize | 480 | 1,016 | 6,154 | 139,022 | 2.5 |
| serde_json (Rust) | Value | 550 | 887 | 4,005 | 134,163 | 3.3 |
| RapidJSON | Writer | 717 | 1,033 | 9,017 | 134,260 | 4.0 |
??? info "Times in µs on x86-64 with Clang 23"
**validate**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | accept | 255 | 512 | 1,055 | 34,729 | 1.1 |
| yyjson | insitu (copy untimed), read (raw numbers) | 334 | 811 | 2,580 | 83,153 | 1.5 |
| sonic-rs (Rust) | IgnoredAny | 501 | 970 | 2,249 | 54,369 | 1.8 |
| Boost.JSON | SAX, SAX (numbers: none) | 571 | 910 | 1,684 | 55,498 | 2.3 |
| simdjson | DOM | 306 | 732 | 3,206 | 113,452 | 1.7 |
| sonic-cpp | Document [U] | – (U 448) | – (U 1,032) | – (U 3,143) | – (U 94,884) | – (U 1.8) |
| Glaze | validate_json | 588 | 1,431 | 1,867 | 75,571 | 2.4 |
| serde_json (Rust) | IgnoredAny [U], from_utf8 + IgnoredAny | 624 (U 484) | 1,440 (U 1,370) | 2,406 (U 2,314) | 64,720 (U 58,247) | 2.4 (U 1.9) |
| nlohmann/json `json` | accept | 658 | 1,390 | 4,265 | 71,982 | 3.5 |
| simd-json (Rust) | tape (copy) | 567 | 1,430 | 4,359 | 207,551 | 2.9 |
| RapidJSON | SAX (default precision), SAX (default precision) (no encoding check) [U], SAX (string stream, default precision), SAX (string stream, default precision) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,756 (U 1,132) | 1,971 (U 1,572) | 4,118 | 198,915 (U 131,204) | 8.2 (U 4.1) |
**sum**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse | 374 | 923 | 3,846 | 85,286 | 1.8 |
| yyjson | insitu (copy untimed) | 399 | 946 | 3,445 | 88,447 | 1.9 |
| simdjson | DOM, On-Demand | 394 | 977 | 4,117 | 114,602 | 2.2 |
| sonic-rs (Rust) | visitor | 501 | 1,123 | 4,397 | 59,583 | 2.5 |
| sonic-cpp | Document [U] | – (U 502) | – (U 1,175) | – (U 3,754) | – (U 104,583) | – (U 2.1) |
| Boost.JSON | SAX | 580 | 928 | 5,809 | 56,774 | 2.4 |
| nlohmann/json `json` | sax | 728 | 1,393 | 5,428 | 89,481 | 3.9 |
| simd-json (Rust) | visitor (copy) | 681 | 1,650 | 5,083 | 210,185 | 3.5 |
| serde_json (Rust) | visitor | 1,311 | 2,288 | 7,127 | 129,497 | 4.6 |
| RapidJSON | SAX, SAX (no encoding check) [U], SAX (string stream), SAX (string stream) (no encoding check) [U], insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,816 (U 1,187) | 2,224 (U 1,765) | 10,719 | 206,811 (U 135,945) | 8.5 (U 4.3) |
| Glaze | generic (double, strict), lazy_json [P] | 2,427 | 3,972 | 12,165 | 253,385 (P 196,582) | 9.2 (P 5.6) |
**select**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse, structs (from_json) | 320 | 759 | 1,390 | 88,083 | 1.6 |
| yyjson | insitu (copy untimed), read (raw numbers) | 351 | 788 | 2,632 | 92,551 | 1.6 |
| simdjson | DOM, On-Demand (key selectors) [P], On-Demand [P], On-Demand structs [P] | 316 (P 181) | 731 (P 495) | 3,262 (P 702) | 120,808 (P 48,823) | 1.9 (P 1.1) |
| sonic-rs (Rust) | structs | 504 | 1,033 | 2,328 | 90,486 | 2.0 |
| Glaze | structs (validate_skipped), structs [P] | 544 (P 345) | 1,368 (P 419) | 1,924 (P 444) | 85,745 (P 66,442) | 2.2 (P 1.6) |
| sonic-cpp | Document [U] | – (U 460) | – (U 1,011) | – (U 3,204) | – (U 108,245) | – (U 1.9) |
| serde_json (Rust) | from_utf8 + structs, structs [U] | 701 | 1,532 | 2,529 (U 2,402) | 107,316 | 3.0 |
| Boost.JSON | parse, parse_into structs | 776 | 1,389 | 7,121 | 97,681 | 3.2 |
| simd-json (Rust) | structs (copy) | 679 | 1,731 | 5,144 | 239,164 | 3.5 |
| RapidJSON | insitu (copy untimed), insitu (copy untimed) (no encoding check) [U] | 1,762 (U 1,144) | 2,198 (U 1,817) | 11,464 | 209,290 (U 142,852) | 8.2 (U 4.1) |
| nlohmann/json `json` | parse, structs (from_json) | 3,602 | 5,124 | 11,831 | 444,210 | 20.2 |
**edit**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | editable, editable (source numbers) [N] | 526 | 1,127 | 5,674 (N 2,443) | 196,370 | 2.9 |
| sonic-cpp | Document [U] | – (U 711) | – (U 1,472) | – (U 7,996) | – (U 183,852) | – (U 3.2) |
| yyjson | insitu (copy untimed) + mut copy, insitu (copy) + mut copy | 918 | 1,548 | 7,155 | 270,654 | 3.9 |
| sonic-rs (Rust) | Value | 1,197 | 2,389 | 7,744 | 390,952 | 6.9 |
| Boost.JSON | parse | 1,855 | 2,596 | 12,765 | 350,286 | 8.3 |
| simd-json (Rust) | borrowed (copy), owned (copy) | 1,862 | 4,194 | 14,896 | 391,369 | 9.0 |
| RapidJSON | insitu (copy untimed), insitu (copy untimed) (no encoding check) [U], insitu (copy) | 2,635 (U 1,952) | 3,103 (U 2,728) | 21,139 | 333,913 (U 267,081) | 11.8 (U 7.7) |
| nlohmann/json `json` | ordered_json, parse | 3,069 | 4,905 | 16,702 | 666,261 | 13.1 |
| serde_json (Rust) | Value | 4,042 | 6,586 | 16,631 | 632,766 | 17.6 |
| Glaze | generic (strict), generic [P] | 4,341 | 7,322 | 26,786 | 725,881 | 18.3 (P 17.7) |
**minify**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | parse + dump, parse + dump (source numbers) [N] | 518 | 1,040 | 5,043 (N 2,456) | 146,125 | 2.4 |
| yyjson | insitu (copy untimed) + write, read (raw numbers) + write [N] | 620 | 1,189 | 6,283 (N 3,472) | 154,016 | 3.0 |
| sonic-cpp | Document + Serialize [U] | – (U 710) | – (U 1,485) | – (U 8,122) | – (U 166,935) | – (U 3.1) |
| simdjson | DOM + to_string, minify [P] | 783 (P 105) | 1,395 (P 269) | 8,303 (P 337) | 275,007 (P 45,087) | 4.0 (P 0.5) |
| sonic-rs (Rust) | Value | 1,000 | 2,097 | 7,795 | 215,280 | 5.4 |
| Boost.JSON | parse + serialize | 1,924 | 2,569 | 12,622 | 329,090 | 8.4 |
| simd-json (Rust) | borrowed (copy) | 1,889 | 4,196 | 15,716 | 414,377 | 9.2 |
| RapidJSON | Document + Writer, SAX to Writer, SAX to Writer (no encoding check) [U], SAX to Writer (raw numbers) (no encoding check) [U N], SAX to Writer (raw numbers) [N], SAX to Writer (string stream), SAX to Writer (string stream) (no encoding check) [U] | 2,808 (U N 2,022) | 2,975 (U 2,480) | 22,589 (N 14,986) | 358,807 (U 261,527) | 12.7 (U 7.2) |
| serde_json (Rust) | Value | 4,012 | 6,583 | 17,306 | 509,702 | 16.5 |
| nlohmann/json `json` | parse + dump | 4,445 | 5,713 | 16,711 | 543,033 | 21.4 |
| Glaze | generic (strict), minify_json [P] | 4,132 (P 444) | 7,465 (P 1,059) | 26,962 (P 2,222) | 528,811 (P 91,153) | 17.2 (P 1.9) |
**write**
| library | variant | `twitter.json` | `citm_catalog.json` | `canada.json` | `jeopardy.json` | one status (2.5 KB) |
| --- | --- | --: | --: | --: | --: | --: |
| **nlohmann/json `json_document`** | dump, dump (source numbers) [N] | 160 (N 154) | 311 (N 279) | 3,714 (N 989) | 67,038 (N 64,301) | 0.9 |
| yyjson | write | 218 | 386 | 3,476 | 71,247 | 1.4 |
| sonic-cpp | Serialize | 224 | 475 | 4,850 | 65,131 | 1.3 |
| nlohmann/json `json` | dump | 451 | 628 | 4,506 | 116,529 | 2.0 |
| Glaze | generic | 399 | 748 | 4,220 | 128,020 | 2.0 |
| simdjson | to_string | 389 | 656 | 4,896 | 157,616 | 2.1 |
| sonic-rs (Rust) | Value | 434 | 927 | 3,987 | 109,176 | 2.8 |
| Boost.JSON | serialize | 478 | 916 | 5,615 | 141,846 | 2.5 |
| simd-json (Rust) | borrowed | 582 | 800 | 5,147 | 122,623 | 2.8 |
| serde_json (Rust) | Value | 550 | 887 | 4,005 | 134,163 | 3.3 |
| RapidJSON | Writer | 627 | 1,008 | 9,596 | 123,764 | 3.7 |
F. Behavior changes
Visible changes that came with the speed:
- Float output (#5634). Żmij writes the shortest decimal that reads back as the same
double, and the closest one if there are several. Of one million randomdoublebit patterns, 1,425 (0.14%) are now written differently: 810 with fewer digits (5.3165205877497296e+16becomes5.31652058774973e+16), 615 with the same number of digits but the closer ones. They all read back as the samedouble, and none of one million short decimals changes. Incanada.json, 69 of the 111,126 numbers come out differently. - Error positions (#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). The built-in handlers behind
parse()andaccept()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). 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, with the BON8 benchmarks removed
because 3.12.0 has no BON8.
| machine | Apple M1 Max laptop on AC power, macOS 27.0.1 |
| compiler | Apple clang 21, libc++ |
| flags | -O3 -flto -DNDEBUG, the compiler's default standard, C++14 |
| suite | 3 rounds × 4 repetitions per version, random interleaving, each round of a version started only when the load average was below 4 and repeated when it rose above 5 while running; of 3.12.0 and 3.13.0, the round that was 4.2% and 1.8% slower overall than the other two was dropped; the median of the remaining 8 |
| noise | the coefficient of variation was 3% or less for 172 of the 182 rows with 3.12.0 and 174 with 3.13.0; the largest was 7.3%, not counting one 3.12.0 run of a 1.5 µs benchmark that a disturbance made 1.4× as slow |
| inputs | the files of Appendix A, and number-heavy files |
| Intel check | the suite again on a MacBook Pro (15-inch, 2016) with an Intel Core i7-6920HQ (Skylake, 4 cores), macOS 12.0.1, Apple clang 13, the same flags with C++11; started below a load of 3, repeated above 4; the first round of each version dropped (3.12.0's was 4.7% slower than the others); the coefficient of variation was 3% or less for 120 rows with 3.12.0 and 161 of 234 with 3.13.0, so single rows are less reliable than on the M1 Max; geometric mean 3.07×, and two rows below 1×: copying citm_catalog.json without destroying the copy (0.93×) and == of it and a copy (0.95×), both in each round |
Setup of the Google Benchmark comparison of 3.12.0 and 3.13.0.
The other measurements. The timeline is
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.
Compile times. The two programs of 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 |
|---|---|---|---|---|---|
Apple clang -O0 |
count_numbers |
955 | 1,320 | 689 | 918 |
server_config |
1,113 | 1,496 | 863 | 1,136 | |
Apple clang -O2 |
count_numbers |
1,393 | 2,081 | 1,458 | 1,685 |
server_config |
1,754 | 2,546 | 1,925 | 2,169 | |
GCC 16 -O0 |
count_numbers |
1,569 | 2,548 | 1,725 | 1,700 |
server_config |
2,025 | 2,985 | 2,302 | 2,326 | |
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.
??? example "The programs and the build commands"
```cpp
// Reads a JSON file and adds up all of its numbers (the "sum" use case of the benchmark).
#include <cstddef>
#include <fstream>
#include <iostream>
#ifdef USE_MODULE
import nlohmann.json;
#else
#include <nlohmann/json.hpp>
#endif
using nlohmann::json;
static double sum_numbers(const json& j)
{
switch (j.type())
{
case json::value_t::array:
case json::value_t::object:
{
double sum = 0;
for (const auto& element : j)
{
sum += sum_numbers(element);
}
return sum;
}
case json::value_t::number_integer:
case json::value_t::number_unsigned:
case json::value_t::number_float:
return j.get<double>();
default:
return 0;
}
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
return 1;
}
std::ifstream in(argv[1]);
const json j = json::parse(in);
std::cout << sum_numbers(j) << '\n';
}
```
<p class="nj-code-caption" markdown>`count_numbers.cpp`.</p>
```cpp
// Reads a configuration file into a struct, changes it, and writes it back indented.
#include <fstream>
#include <iostream>
#include <string>
#include <vector>
#ifdef USE_MODULE
import nlohmann.json;
#else
#include <nlohmann/json.hpp>
#endif
using nlohmann::json;
struct server_config
{
std::string host;
int port = 0;
double timeout = 0;
std::vector<std::string> tags;
};
void to_json(json& j, const server_config& c)
{
j = json{{"host", c.host}, {"port", c.port}, {"timeout", c.timeout}, {"tags", c.tags}};
}
void from_json(const json& j, server_config& c)
{
j.at("host").get_to(c.host);
j.at("port").get_to(c.port);
j.at("timeout").get_to(c.timeout);
j.at("tags").get_to(c.tags);
}
int main(int argc, char* argv[])
{
if (argc != 3)
{
return 1;
}
std::ifstream in(argv[1]);
auto config = json::parse(in).get<server_config>();
config.port += 1;
config.tags.push_back("updated");
std::ofstream(argv[2]) << json(config).dump(2) << '\n';
}
```
<p class="nj-code-caption" markdown>`server_config.cpp`.</p>
```sh
# Apple clang: the precompiled header (pch.hpp contains only #include <nlohmann/json.hpp>) ...
clang++ -std=c++20 -O2 -I single_include -x c++-header pch.hpp -o pch.hpp.pch
clang++ -std=c++20 -O2 -I single_include -include-pch pch.hpp.pch -c count_numbers.cpp
# ... and the module
clang++ -std=c++20 -O2 -fcxx-modules -I single_include -c -x c++-module src/modules/json.cppm \
-fmodule-output=nlohmann.json.pcm -o json_mod.o
clang++ -std=c++20 -O2 -fcxx-modules -fmodule-file=nlohmann.json=nlohmann.json.pcm -DUSE_MODULE \
-c count_numbers.cpp
# GCC 16 (Homebrew on macOS, add --sysroot=$(xcrun --show-sdk-path))
g++-16 -std=c++20 -O2 -I single_include -x c++-header pch.hpp -o pch.hpp.gch
g++-16 -std=c++20 -O2 -I single_include -include pch.hpp -Winvalid-pch -c count_numbers.cpp
g++-16 -std=c++20 -O2 -fmodules -I single_include -c src/modules/json.cppm -o json_mod.o
g++-16 -std=c++20 -O2 -fmodules -DUSE_MODULE -c count_numbers.cpp
```
<p class="nj-code-caption" markdown>The commands for one program at `-O2`; the module variants are linked with
`json_mod.o`.</p>
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.
H. Pull requests by theme
- 1 Specialize for each input source: contiguous input
#5283; streams and
FILE*#5828, #5884; binary strings in bulk #5233; sized sentinels #5268. - 2 Work on words, not bytes: word-at-a-time strings, UTF-8
runs, and digits #5283, #5738, #5851; indentation #5810;
dump()runs #5285; SSE2 and NEON #5853, #5628;json_documentwithensure_ascii#5879; short strings #5878, #5883; escape table #5822; loop shape #5490; fraction digits #5971; BSON keys #5968; UTF-8 checks of binary strings #5982; one SIMD macro set #5921. - 3 Own the number conversions: Eisel-Lemire #5617;
long numbers #5738; one pass over the digits #5851; Żmij #5634; long integers in
dump()#5964; MSVC intrinsics for the 128-bit multiplication #5782. - 4 Don't do unnecessary work: float token text
#5852; token buffer #5234, #5828; positions on error #5809; binary readers' copy
#5501;
ordered_jsongrowth #5609; arrays at their final size #5882; moving keys #5458, #5855; separator check #5592; CBOR keys #5328;accept()without copies #5978; runs of scalars in binary arrays #5975, #5980, #5981, #6008; BJData marker list #5555;from_json()into containers #5833. - 5 Read the assembly: stack canary #5854; exceptions and inlining #5838, #5840, #5839, #5836; aliasing #5633; forced inlining #5878; branch-free selections #5908; code size #5902.
- 6 Write through a cursor: write buffer #5285;
adapter on the stack #5449; binary sinks #5286; cursor and runs of numbers #5824,
#5885;
to_bson()in one pass #5979. - 7 Fix the algorithm first:
diff()#5461; parse callbacks #5457;operator<#5390;flatten()#5834;unflatten()#5793; JSON Pointer and Patch #5861, #5886;to_bson()#5553;JSON_DIAGNOSTICS#5832; bounded recursion #5285, #5389, #5547; destructor #5762, #5835. - 8 Build a different data structure:
json_document#5620, #5627, #5628, #5622, #5908, #5879;accept()without an index #5970; reading and indexing #5888, #5915, #5889, #5890; editing #5630, #5919, #5926. - Validation: guard pages #5826; differential fuzzing #5827; float checks #5617, #5908; mutation checks #5878.
- The UTF-8 check that came and went: #5185, #5531, #5741, #5746.
I. Details of the themes
Eisel-Lemire (theme 3).
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 doubles, 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.
Ż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:
// 12345678 -> the bytes 1, 2, 3, 4, 5, 6, 7, 8 (one digit per byte)
std::uint64_t eight_digit_bytes(std::uint64_t abcdefgh) noexcept
{
// x / d as (x * (2^k / d + 1)) >> k
const std::uint64_t abcd = (abcdefgh * (((1ull << 40) / 10000) + 1)) >> 40;
const std::uint64_t abcd_efgh = abcdefgh + ((1ull << 32) - 10000) * abcd;
const std::uint64_t ab_ef = ((abcd_efgh * (((1ull << 19) / 100) + 1)) >> 19) & 0x7F0000007Full;
const std::uint64_t ab_cd_ef_gh = abcd_efgh + ((1ull << 16) - 100) * ab_ef;
const std::uint64_t a_c_e_g =
((ab_cd_ef_gh * (((1ull << 10) / 10) + 1)) >> 10) & 0x000F000F000F000Full;
return ab_cd_ef_gh + ((1ull << 8) - 10) * a_c_e_g;
}
Ż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:
// descend into the last child of cur, reversing the link
basic_json& slot = last_child(cur);
take(tmp, slot); // the child's contents
take(slot, prev); // its slot now remembers cur's parent
take(prev, cur); // cur becomes the parent
take(cur, tmp); // the child becomes cur
// on the way up: read the parent back out of prev's last slot, pop that slot, free cur
One 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.
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.
- Stream windows (theme 1). A test
streambufhands 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 astreambufwithout a get area, which takes the old path. - Differential fuzzing of the input kinds. Every fuzz input is also parsed from an
std::istringstream, fromstd::listiterators (the byte path) and from aFILE*, and the value, the exception id, the message, and the byte offset must be identical (#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).
- 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.jsonandcitm_catalog.json. The changes of float output are listed in Appendix F. json_documentagainstjson. 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).- 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).