* Scan json_view strings with SIMD and index large objects Speed up json_view's parser with SIMD scanning and a hash table for large objects. Long runs of string bytes are scanned 16 bytes at a time with NEON (AArch64, GCC and Clang) and SSE2 (x86-64), both baseline instruction sets. Keys keep 16 table checks before the vector loop, because their lengths repeat from record to record; string values get 8, because their lengths vary more. Non-ASCII text is validated 16 bytes at a time with simdjson's "lookup4" check (Keiser and Lemire, 2021), with NEON on AArch64 and, on x86-64, with SSSE3. SSSE3 is not part of baseline x86-64, so the check is compiled for SSSE3 with a function attribute and used only where CPUID reports it, which all x86-64 CPUs since about 2011 do; the answer is cached in a statically initialized atomic, so there is no guard of a local static and no global constructor. The same input is accepted either way. JSON_VIEW_NO_SIMD selects the portable code. On x86-64, string runs are now checked vector-first: one SSE2 compare from the first byte finds the end of most keys and short values, instead of a branch per byte for the first 8-16 bytes. AArch64 keeps the byte-wise steps, where a NEON mask costs more and the branches predict well. Entering an object or array no longer stalls: open() stores the parent's frame field by field instead of building it on the stack and reading it back with wider loads, which waited for the narrower stores to retire. Objects with 128 members or more get an open-addressing hash table built when the object closes, so operator[], at(), find(), contains(), count(), value(), and JSON pointers take constant time on average in such objects; of duplicate keys, the first is kept, as for the linear search. The idea comes from Boost.JSON. simdjson is credited in simd.hpp's SPDX block, the README, and license.md. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fix GCC module build and MSVC C4127 in the json_view SIMD code GCC ignores the target attribute in modules, so the SSSE3 dispatch is disabled for the module interface (the check stays portable, SSE2 is kept). Make the 8-vs-16 byte unrolling condition in scan_string_run a preprocessor/template split to avoid a constant condition (C4127). Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Remove an accidentally committed GCC module cache Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Bound the probe length of the large-object hash index The key hash is not seeded, so keys chosen to collide made building the table quadratic (20,000 colliding keys took 470 ms to parse). A key may now sit at most 64 slots from its home slot; if a key would sit further away, the table is dropped and the object is searched linearly. Lookups stop after the same distance. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Release the spare capacity of the hash index in shrink_to_fit shrink_to_fit() now trims the tables of large objects like the node array and the decoded strings, and the list of large objects is released as soon as the tables are built. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Use the portable string scan on non-x86 targets that define __SSE2__ WebAssembly with -msse2 defines __SSE2__, but has no <cpuid.h> or x86 intrinsics headers. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Document the hash index number in node::extra Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Keep the last of duplicate keys in the object hash index Lookups in objects with 128 members or more now return the last member of a repeated key, like the linear search of smaller objects and like materialize() and basic_json::parse(). build_object_index let the first occurrence win, so the same text gave different results depending on the size of the object. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Keep the first of duplicate keys in the object hash index again Lookups in objects with 128 members or more return the first member of a repeated key again, like the linear search of smaller objects. This undoes the code change of a69542046; its test now expects the first member from lookups (with and without a table) and the last value from materialize() and basic_json::parse(). Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fix clang-tidy 22 findings in the json_view headers Silence bugprone-casting-through-void for the SSE loads (a reinterpret_cast would trip -Wcast-align=strict), use auto for a cast initialiser, add parentheses to a mixed expression, and name bugprone-std-namespace-modification in the NOLINTs of the tuple_size/tuple_element specialisations. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fix clang-tidy 22 findings in unit-json_view.cpp and unit-json_view_builder.cpp Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Avoid GCC useless-cast and strict-overflow warnings in unit-json_view.cpp Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Index strings with size_t in the colliding-keys test (MSVC C4244 on Win32) Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Make json_view.hpp pass include-what-you-use ci_single_binaries runs IWYU with --error on every header. For json_view.hpp it suggested adding <array> (std::array is used), the headers that json.hpp already provides (abi_config, abi_macros, input_adapters, json_pointer, cpp_future, string_concat, value_t, json_fwd), and <version> for std::nullptr_t, and removing <cstddef>. - include <array> - keep <cstddef> (nullptr_t, size_t; IWYU attributes them to <version> and <cstring>) - tell IWYU not to suggest the headers that json.hpp provides: the amalgamated json_view.hpp only includes json.hpp, so including them here would duplicate their definitions Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Move the dump, comparison, and large-object tests to unit-json_view_dump.cpp The dump and comparison tests moved to their own file in the dump pull request; the hash index tests of this branch join them there. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Regenerate the amalgamated headers Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Follow the renames from the review of the view builder The review of #5620 renamed document_data's tape/tape_size/tape_cap to nodes/node_count/node_capacity and made the decode arena a std::vector<char>; adapt the code of this change to it. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Rename slots in the object index for Qt compatibility Qt defines slots as a macro, so a Qt header included before json_view.hpp broke the declarations of the local variable (see #5865). The test variable is renamed as well. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Address the review of the json_view object index and SIMD scan From gregmarr's review of #5628: - Docs and comments say that the hash index is one of the object's keys. - object_index.hpp: conditional_static_cast for the hash, "slot" for the table position, no flag for a degenerate table, and a static_assert that the smallest indexed object is larger than the probe window. - scan.hpp unrolls with NLOHMANN_VIEW_REPEAT_FIRST_8/SECOND_8. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Regenerate the amalgamated headers Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Encode JSON_VIEW_NO_SIMD and JSON_VIEW_USE_SSSE3 in the ABI namespace Both macros select between definitions of the same inline functions, so translation units that disagree on them violated the ODR. Like the other ABI-affecting macros, they now add a tag to the inline namespace (_vns and _vssse3), and the docs replace the "Define consistently" warning with the usual ABI compatibility note. From gregmarr's review of #5628. nlohmann_json.natvis covers twice as many tags now, so it lists four times as many namespaces (14.7 MB instead of 3.4 MB). Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Regenerate the amalgamated headers Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Do not include type_traits.hpp directly in the object index json.hpp, which object_index.hpp already includes, provides it; included directly, the json_view amalgamation (which only treats nlohmann/json.hpp as external) copied the json internals into json_view.hpp a second time, so the fuzzers, which include both single headers, failed with redefinitions (make_void, integer_sequence, priority_tag, ...). Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Compare keys with key_equals() in the object hash index As the linear lookups do since the second review round of #5622. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Regenerate the amalgamated headers Signed-off-by: Niels Lohmann <mail@nlohmann.me> --------- Signed-off-by: Niels Lohmann <mail@nlohmann.me>
18 KiB
Zero-copy JSON views
#!cpp <nlohmann/json_view.hpp> adds a read-only, non-owning way to look at a parsed JSON text, as an alternative to
building a basic_json tree with parse().
The problem
basic_json::parse() builds a tree of basic_json values: one allocation for every
array and object, and every string copied into its own std::string. That is the right trade-off when the program
goes on to read and write the value freely, but it does more work than necessary when only a small part of a large
JSON text is actually needed, or when the same text is parsed over and over (many small messages, for instance) and
most of the resulting tree is thrown away almost immediately.
The idea
basic_json_document::parse() parses the same JSON grammar, with the same
options, but instead of a tree it builds a flat index of the values it found: one
16-byte entry per value (and one per object key), in document
order. Integers are converted while parsing and stored in their entry. Floating-point numbers and strings are
referenced in place in the source text: a float is converted when it is read, and only strings that contain escape
sequences are decoded, into one shared buffer owned by the document.
basic_json_view is a small, trivially copyable handle (two pointers) into that
index. It gives you the read-only, type-inspection part of the basic_json interface --
type() and the is_*() predicates,
size()/empty() -- as well as element access
(operator[], at,
front/back), lookup
(find, contains,
count), and iteration
(begin/end,
items) -- without ever allocating a basic_json value. When you do need an
actual basic_json value for a subtree, materialize() builds exactly the
one parse() would have produced for it.
How to use it
Include <nlohmann/json_view.hpp> in addition to (or instead of) <nlohmann/json.hpp>. Parse into a
json_document, inspect its root(), and
materialize() when you need a real value. materialize() works on any view, not just the root, so you can turn just
the subtree you need into a value:
??? example "Example: parse a document, inspect its root, and materialize it"
```cpp
--8<-- "examples/json_document.cpp"
```
Output:
```json
--8<-- "examples/json_document.output"
```
ordered_json_document is the equivalent for
ordered_json, just as ordered_json is to
json.
Ownership and lifetime
A document either borrows the text it was parsed from, or owns its own copy of it; call
owns_source() to find out which happened.
parse() decides this from the value category and type of its argument (an
lvalue #!cpp std::string is borrowed; an rvalue #!cpp std::string is moved in, owned without a copy; a stream is
read into an owned buffer; and so on -- see parse's Notes for the full
table). parse_copy() always owns a copy, regardless of the input.
!!! warning "A borrowed document depends on your buffer"
If a document borrows its text, that text **must outlive the document** (and every view taken from it) and must
stay **unmodified** for as long as the document exists, since floating-point values and unescaped strings are
read from it lazily. Reading or writing through a view after the underlying buffer is gone is undefined behavior,
exactly as it would be for a dangling `#!cpp std::string_view`.
A view is valid only while all of the following hold:
- the document is alive,
- the document has not been re-parsed since the view was taken (with
read()orparse()into it), - the document has not had
shrink_to_fit()called on it since, and - if the document borrows its source text, that text is still alive and unmodified.
Moving the basic_json_document<> itself is fine and does not invalidate its views: the index is a separate heap
allocation that keeps its address across the move. Take a fresh view from root()
whenever any of the other conditions above was not met.
A view can dangle, exactly like a #!cpp std::string_view: nothing stops you from using it after its document is
gone. This is deliberate. A view is two pointers and trivially copyable; making it safe (a #!cpp std::shared_ptr in
the document and a #!cpp std::weak_ptr in each view) would make it larger, add reference counting to every copy,
and a lock() to every access -- exactly the costs the view exists to avoid. What is caught is the obvious case:
because a view dies with its document, root() is not callable on a temporary
document: #!cpp auto v = json_document::parse(text).root(); does not compile. Give the document a name first.
??? example "Example: borrowed and owned documents, and when views become invalid"
```cpp
--8<-- "examples/json_view_ownership.cpp"
```
Output:
```json
--8<-- "examples/json_view_ownership.output"
```
What is the same as parse()
- Accept/reject.
accept()andparse()accept and reject exactly the same inputs asbasic_json::accept()/basic_json::parse(), with the sameignore_commentsandignore_trailing_commasoptions. - Errors. A failing parse throws the same exception -- the same id, message, and position -- because on failure
the library's own parser is run on the same bytes to produce the diagnostic. Valid input never pays for this, and
the replay builds no values: it costs about a lexing pass up to the error.
#!cpp allow_exceptions == falseskips the replay and gives a discarded document instead of throwing, just as it gives a discarded value for#!cpp basic_json::parse(). - Number classification. An integer literal that does not fit into the 64-bit integer type becomes a
floating-point value, exactly as it does for
#!cpp basic_json::parse(). - Macros.
JSON_STRICT_NUL_HANDLINGandJSON_NOEXCEPTION/JSON_THROW_USERbehave the same way they do for<nlohmann/json.hpp>. These are the macros that change what the parser accepts or how it reports errors. The others either do not apply (JSON_DIAGNOSTICSneeds parent pointers inbasic_jsonvalues) or only affect the valuesmaterialize()produces, throughBasicJsonType.
What is different
- Only 64-bit integers.
basic_json_document<BasicJsonType>requiresBasicJsonType::number_integer_tandnumber_unsigned_tto both be 64 bits wide; this is a compile-time#!cpp static_assert. - A 4 GiB input limit. An input of 4294967280 bytes (4 GiB minus 16 bytes) or more throws
out_of_range.416, a limit#!cpp basic_json::parse()does not have. It keeps an index entry at 16 bytes: offsets and counts are 32 bits wide. The limit is 16 bytes below 2^32, because the parser reads in 16-byte steps, and a position one step past the end must still fit. - A stream is always read to its end. There is no partial/streaming read of an
#!cpp std::istream: the stream is read to its end into a buffer the document owns, and parsing starts after that. - No source positions on
materialize(). Even withJSON_DIAGNOSTIC_POSITIONSenabled,materialize()does not set them. The index has the start offset of every value, but not the end of arrays, objects, and escaped strings, which the positions also need. - Objects iterate in document order.
begin()/end()anditems()visit an object's members in the order they appear in the source text.basic_json's defaultobject_tis astd::map, which sorts by key, so iterating amaterialize()d value can print members in a different order than iterating the view they came from. - Chained access is safe.
operator[]with a missing key, an index out of range, or an unresolvable JSON pointer returns a discarded view, andoperator[]on a discarded view returns a discarded view without throwing:#!cpp v["a"]["b"][0]can be tested once at the end. Type errors on values that exist (a key on an array, an index on an object) still throw, andatthrows for every missing value. - Duplicate keys: lookups find the first member,
parse()keeps the last. See Duplicate keys below. - No
JSON_DIAGNOSTICSpath. Exceptions thrown bybasic_json_view's own element access and lookup functions never carry the JSON Pointer pathJSON_DIAGNOSTICSwould otherwise add: the view has nobasic_jsonvalue to point at, so the exception is created without one, regardless of howBasicJsonTypewas built. - Ordering comparisons are not provided by
basic_json_view-- there is no#!cpp operator<.operator==andoperator!=are provided, though: two views, or a view and aBasicJsonTypevalue, compare equal exactly whenmaterialize()orparse()would produce equal values for them, without ever building a tree to do it. For ordering, too,materialize()is the way to get a value you can compare.
Duplicate keys
!!! warning "A lookup in a view and in the parsed value can give different answers"
If an object in the source text repeats a key, [`operator[]`](../api/basic_json_view/operator%5B%5D.md),
[`at`](../api/basic_json_view/at.md), [`find`](../api/basic_json_view/find.md),
[`value`](../api/basic_json_view/value.md), [`contains`](../api/basic_json_view/contains.md), and JSON pointer
resolution all return the **first** member with that key. `basic_json::parse()` instead keeps the **last** value
of a repeated key, so for `#!cpp {"a":1,"a":2}`, `#!cpp view["a"]` is `#!cpp 1` while `#!cpp parse(text)["a"]` is
`#!cpp 2`. [`count`](../api/basic_json_view/count.md) counts every member with the key, so it is `#!cpp 2` here.
begin()/end() and
items() visit every occurrence, in document order, and
size() counts all of them, so #!cpp view.size() can be larger than
#!cpp view.materialize().size().
Why the first? A lookup can stop as soon as it finds a match. Returning the last member would force every lookup to scan all members of the object, even when the key is found at the very first one: this made lookups in small objects 1.6 to 3.4 times slower. The hash index of the keys of objects with 128 members or more leads to the first member of a key as well. Other zero-copy parsers that index the source text, such as yyjson and simdjson, also return the first member. RFC 8259 only says that names within an object SHOULD be unique and that the behavior of a receiver that sees duplicates is unpredictable, so neither choice is wrong.
What stays the same as parse()? materialize() and
get<std::map<...>>() replay every member in order, so they keep the last value
exactly like #!cpp basic_json::parse() (at the position of the first occurrence of the key, for an
ordered_json).
How do I get the value parse() would give? Either call #!cpp view.materialize() and look the key up in the
result, or iterate the members and keep the last match:
// the last member with the key "a", as parse() would keep it
json_view last;
for (const auto item : view.items())
{
if (item.key() == "a")
{
last = item.value();
}
}
If you cannot trust the source text to have unique keys, check size() against the
number of distinct keys, or reject duplicates when iterating.
Getting values out without copying
get<T>() converts many T directly from the flat index, without ever building a
basic_json value for the conversion: #!cpp bool, arithmetic types, #!cpp std::nullptr_t,
#!cpp std::string/other #!cpp std::basic_strings (copied once), basic_json/ordered_json (via
materialize()), basic_json_view itself, #!cpp std::vector<U>, and
#!cpp std::map/#!cpp std::unordered_map with string-like keys. Every other type -- #!cpp std::list,
#!cpp std::pair, #!cpp std::array, enumerations, user types with a from_json() -- goes through
materialize().get<T>() instead: the subtree is built into a real
basic_json value first, exactly as parse() would, and converted from there.
Two conversions never copy at all:
get_string()(equivalently,#!cpp get<string_view_t>()) returns a string as astring_view_tpointing into the document'ssource()text -- or, for a string that contains escape sequences, into the document's own buffer of decoded strings -- instead of allocating a new#!cpp std::string.number_token()returns a number exactly as it was written in the source, e.g.#!cpp "1.50",#!cpp "1E2", or an integer with more digits than any number type holds, instead of rounding it into a#!cpp double/#!cpp int64_tthe way#!cpp get<T>()(andbasic_json::parse()) would.
Both results are only valid as long as the view -- and, for a string with no escapes, the borrowed source text -- is.
Writing a view back
dump() serializes a view directly from the flat index, without ever building a
basic_json value. An object's members are written in document order, not sorted by key, and every occurrence of a
repeated key is written, not only the last one -- the same two ways iteration already differs
from a materialize()d value, see Duplicate keys.
#!cpp materialize().dump() gives a different result in both respects for a json_view.
By default, numbers are written the way basic_json::dump() would.
number_format::source instead copies every number exactly as it was
written in the source text -- a price like #!cpp 19.90, a long order or account ID with more digits than any number
type holds, or a high-precision coordinate -- something basic_json cannot do at all, since parsing already reduces
a number to its parsed #!cpp double/#!cpp int64_t value.
operator<< writes a view to a stream the way basic_json's does, using
the stream's width/fill for indentation.
Choosing between json, ordered_json, the SAX interface, and json_view
json / ordered_json |
SAX interface | json_document / json_view |
|
|---|---|---|---|
| Ownership | owns every value | owns nothing; you decide what to keep, in your handler | borrows or owns the text; the index is always owned by the document |
| Mutability | freely mutable | not applicable (a one-shot event stream) | read-only |
| What you get | a full tree you can read, write, and keep as long as you like | a sequence of callbacks; whatever your handler builds from them | a flat index plus, on demand, materialize()d json/ordered_json values for the parts you actually use |
| Typical use | general-purpose JSON handling: config, request/response bodies you build or modify, anything you hold onto | validating or projecting a text into your own data structure without ever holding the whole thing as JSON | large or high-volume input where you only need part of it, or need it repeatedly, and can keep the source text (or a copy) alive for as long as the document lives |
Version history
- Added in version 3.13.0.