From 7071ced153acc030b508b47ddf9ae7529405c42c Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Sun, 18 Oct 2026 10:02:03 +0200 Subject: [PATCH] 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 --- docs/mkdocs/docs/api/basic_json_view/at.md | 4 +- .../docs/api/basic_json_view/contains.md | 4 +- docs/mkdocs/docs/api/basic_json_view/count.md | 3 +- docs/mkdocs/docs/api/basic_json_view/find.md | 4 +- .../docs/api/basic_json_view/operator[].md | 6 +-- docs/mkdocs/docs/api/basic_json_view/value.md | 4 +- docs/mkdocs/docs/features/json_view.md | 2 +- docs/mkdocs/docs/home/architecture.md | 4 +- include/nlohmann/detail/view/builder.hpp | 2 +- .../nlohmann/detail/view/document_data.hpp | 2 +- include/nlohmann/detail/view/macro_scope.hpp | 8 +++- .../nlohmann/detail/view/macro_unscope.hpp | 2 + include/nlohmann/detail/view/node.hpp | 2 +- include/nlohmann/detail/view/object_index.hpp | 45 +++++++++---------- include/nlohmann/detail/view/scan.hpp | 6 +-- tests/src/unit-json_view_dump.cpp | 4 +- 16 files changed, 54 insertions(+), 48 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json_view/at.md b/docs/mkdocs/docs/api/basic_json_view/at.md index 1d4ececed..ce142c23f 100644 --- a/docs/mkdocs/docs/api/basic_json_view/at.md +++ b/docs/mkdocs/docs/api/basic_json_view/at.md @@ -77,8 +77,8 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics 1. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. - Objects with 128 or more members get a hash index while parsing, so that a lookup in them takes constant time - on average. + Objects with 128 or more members get a hash index of their keys while parsing, so that a lookup in them takes + constant time on average. 2. Linear in `idx`: elements are skipped one at a time from the first one, since they are not a fixed size in the index (unlike `BasicJsonType`'s array, which is random-access). 3. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at diff --git a/docs/mkdocs/docs/api/basic_json_view/contains.md b/docs/mkdocs/docs/api/basic_json_view/contains.md index 0564cf970..5f15adf11 100644 --- a/docs/mkdocs/docs/api/basic_json_view/contains.md +++ b/docs/mkdocs/docs/api/basic_json_view/contains.md @@ -35,8 +35,8 @@ No-throw guarantee: this function never throws exceptions. 1. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. - Objects with 128 or more members get a hash index while parsing, so that a lookup in them takes constant time - on average. + Objects with 128 or more members get a hash index of their keys while parsing, so that a lookup in them takes + constant time on average. 2. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at that level or the index into the array -- as for [`operator[]`](operator[].md#complexity) and [`at`](at.md#complexity) with a JSON pointer. diff --git a/docs/mkdocs/docs/api/basic_json_view/count.md b/docs/mkdocs/docs/api/basic_json_view/count.md index 698c7e0be..7d6769e0d 100644 --- a/docs/mkdocs/docs/api/basic_json_view/count.md +++ b/docs/mkdocs/docs/api/basic_json_view/count.md @@ -28,7 +28,8 @@ No-throw guarantee: this function never throws exceptions. Linear in the number of members: unlike the lookup functions, `count()` does not stop at the first match, because it has to count every member with the key. Members are compared one after another, in document order. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its -content. +content. The hash index of the keys of objects with 128 or more members does not help here, since every member +is visited. ## Notes diff --git a/docs/mkdocs/docs/api/basic_json_view/find.md b/docs/mkdocs/docs/api/basic_json_view/find.md index 723cad70b..8345dee59 100644 --- a/docs/mkdocs/docs/api/basic_json_view/find.md +++ b/docs/mkdocs/docs/api/basic_json_view/find.md @@ -28,8 +28,8 @@ No-throw guarantee: this function never throws exceptions. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. -Objects with 128 or more members get a hash index while parsing, so that a lookup in them takes constant time on -average. +Objects with 128 or more members get a hash index of their keys while parsing, so that a lookup in them takes +constant time on average. ## Notes diff --git a/docs/mkdocs/docs/api/basic_json_view/operator[].md b/docs/mkdocs/docs/api/basic_json_view/operator[].md index e24f7fb76..354813bdf 100644 --- a/docs/mkdocs/docs/api/basic_json_view/operator[].md +++ b/docs/mkdocs/docs/api/basic_json_view/operator[].md @@ -83,8 +83,8 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content, so a key of a different length than `key` is rejected without touching the source text. - Objects with 128 or more members get a hash index while parsing, so that a lookup in them takes constant time - on average. + Objects with 128 or more members get a hash index of their keys while parsing, so that a lookup in them takes + constant time on average. 2. Linear in `idx`: elements are skipped one at a time from the first one, since they are not a fixed size in the index (unlike `BasicJsonType`'s array, which is random-access). 3. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at @@ -116,7 +116,7 @@ document. member in order and so keep the *last* value for a repeated key, so `#!cpp v["a"]` and `#!cpp v.materialize()["a"]` can differ. To get the value `parse()` would give, use [`materialize()`](materialize.md) or iterate the members with [`items()`](items.md) and keep the last match. - The hash index of a larger object (128 members or more) leads to the first member of a key as well. + The key hash index of a larger object (128 members or more) leads to the first member of a key as well. [`begin()`](begin.md)/[`end()`](end.md) and [`items()`](items.md) iterate over *all* members, including duplicates, in document order. See [Duplicate keys](../../features/json_view.md#duplicate-keys) and [`size()`](size.md#notes). diff --git a/docs/mkdocs/docs/api/basic_json_view/value.md b/docs/mkdocs/docs/api/basic_json_view/value.md index 2ea9a52c1..47e7b0bbb 100644 --- a/docs/mkdocs/docs/api/basic_json_view/value.md +++ b/docs/mkdocs/docs/api/basic_json_view/value.md @@ -70,8 +70,8 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics 1. Linear in the number of members: as for [`operator[]`](operator[].md#complexity), members are compared one after another, in document order, stopping at the first match. Plus the complexity of converting the found member to `T` (see [`get`](get.md)). - Objects with 128 or more members get a hash index while parsing, so that a lookup in them takes constant time - on average. + Objects with 128 or more members get a hash index of their keys while parsing, so that a lookup in them takes + constant time on average. 2. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at that level or the index into the array -- as for the [`operator[]`](operator[].md#complexity) and [`at`](at.md#complexity) overloads that take a JSON pointer. Plus the complexity of converting the resolved value diff --git a/docs/mkdocs/docs/features/json_view.md b/docs/mkdocs/docs/features/json_view.md index 3c8c2f3d9..f3a63022e 100644 --- a/docs/mkdocs/docs/features/json_view.md +++ b/docs/mkdocs/docs/features/json_view.md @@ -180,7 +180,7 @@ document: `#!cpp auto v = json_document::parse(text).root();` does not compile. **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 objects with 128 members or more leads to the first member of a +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. diff --git a/docs/mkdocs/docs/home/architecture.md b/docs/mkdocs/docs/home/architecture.md index 2646ae235..37eccd04a 100644 --- a/docs/mkdocs/docs/home/architecture.md +++ b/docs/mkdocs/docs/home/architecture.md @@ -196,7 +196,7 @@ packet-beta |-------|---------|------------|-------------------------------------------------------------------------------------------------------------------------------| | 0 | `kind` | `uint8_t` | the type, numbered as [`value_t`](../api/basic_json/value_t.md): 0 null, 1 object, 2 array, 3 string, 4 boolean, 5 signed integer, 6 unsigned integer, 7 float | | 1 | `flags` | `uint8_t` | bits 0-1: where a string's bytes are (0: the source text, 1: the buffer of decoded strings, for strings with escapes); bit 2: the value of a boolean | -| 2-3 | `extra` | `uint16_t` | numbers: the number of integer digits (low byte) and fraction digits (high byte), 255 for more; objects: the number of their hash index (1-based), or 0; otherwise 0 | +| 2-3 | `extra` | `uint16_t` | numbers: the number of integer digits (low byte) and fraction digits (high byte), 255 for more; objects: the number of their key hash index (1-based), or 0; otherwise 0 | | 4-7 | `off` | `uint32_t` | where the value starts: the first byte after a string's opening quote (or its position in the buffer of decoded strings), the first byte of a number or literal, the bracket of an array or object | | 8-11 | `len` | `uint32_t` | strings: the length after decoding; floats and literals: the length of the token; arrays and objects: the number of elements | | 12-15 | `next` | `uint32_t` | arrays and objects: the number of nodes of the subtree, including the node itself | @@ -212,7 +212,7 @@ packet-beta step from element to element this way and skip whole subtrees in constant time. - **Offsets** are 32 bits wide, so a document is limited to 4294967279 bytes, 4 GiB minus 16 bytes (a margin below 2^32 for positions one scanner step past the end of the text; `out_of_range.416`). -- **Large objects** (128 members or more) get a hash index after parsing +- **Large objects** (128 members or more) get a hash index for their keys after parsing ([`detail/view/object_index.hpp`](https://github.com/nlohmann/json/blob/develop/include/nlohmann/detail/view/object_index.hpp)): an open-addressing table whose slots hold the distance from the object's node to a key's node, so that a lookup does not compare every key. Of duplicate keys the table leads to the first, as a linear search does. The object's `extra` diff --git a/include/nlohmann/detail/view/builder.hpp b/include/nlohmann/detail/view/builder.hpp index 8a1518294..3eb85ce47 100644 --- a/include/nlohmann/detail/view/builder.hpp +++ b/include/nlohmann/detail/view/builder.hpp @@ -684,7 +684,7 @@ obj_next: return fail(error_code::expected_object_end); close_object: - // a large object gets a hash index (objects only, so that closing + // a large object gets a hash index for its keys (objects only, so that closing // an array pays nothing for this) if (NLOHMANN_VIEW_UNLIKELY(cur_count >= document_data::index_min_members)) { diff --git a/include/nlohmann/detail/view/document_data.hpp b/include/nlohmann/detail/view/document_data.hpp index fdf3cf4ee..8dbb795dc 100644 --- a/include/nlohmann/detail/view/document_data.hpp +++ b/include/nlohmann/detail/view/document_data.hpp @@ -42,7 +42,7 @@ struct document_data std::vector arena; ///< decoded strings that contained escapes (no terminator: they are handed out with their length) std::string owned; ///< owned copy of the input, if any (a std::string, so that its NUL serves as the parser's sentinel) - // hash indexes of large objects (see object_index.hpp) + // hash indexes of the keys of large objects (see object_index.hpp) static constexpr std::uint32_t index_min_members = 128; struct object_index { diff --git a/include/nlohmann/detail/view/macro_scope.hpp b/include/nlohmann/detail/view/macro_scope.hpp index e4b685094..bbe1a240b 100644 --- a/include/nlohmann/detail/view/macro_scope.hpp +++ b/include/nlohmann/detail/view/macro_scope.hpp @@ -70,5 +70,11 @@ #define NLOHMANN_VIEW_LITTLE_ENDIAN 0 #endif +/// eight checks at fixed offsets 0..7 +#define NLOHMANN_VIEW_REPEAT_FIRST_8(X) X(0) X(1) X(2) X(3) X(4) X(5) X(6) X(7) + +/// eight checks at fixed offsets 8..15 +#define NLOHMANN_VIEW_REPEAT_SECOND_8(X) X(8) X(9) X(10) X(11) X(12) X(13) X(14) X(15) + /// sixteen checks at fixed offsets 0..15 -#define NLOHMANN_VIEW_REPEAT16(X) X(0) X(1) X(2) X(3) X(4) X(5) X(6) X(7) X(8) X(9) X(10) X(11) X(12) X(13) X(14) X(15) +#define NLOHMANN_VIEW_REPEAT16(X) NLOHMANN_VIEW_REPEAT_FIRST_8(X) NLOHMANN_VIEW_REPEAT_SECOND_8(X) diff --git a/include/nlohmann/detail/view/macro_unscope.hpp b/include/nlohmann/detail/view/macro_unscope.hpp index 656f1d9a7..a9aec867b 100644 --- a/include/nlohmann/detail/view/macro_unscope.hpp +++ b/include/nlohmann/detail/view/macro_unscope.hpp @@ -19,6 +19,8 @@ #undef NLOHMANN_VIEW_THROW #undef NLOHMANN_VIEW_LITTLE_ENDIAN #undef NLOHMANN_VIEW_REPEAT16 +#undef NLOHMANN_VIEW_REPEAT_SECOND_8 +#undef NLOHMANN_VIEW_REPEAT_FIRST_8 #undef NLOHMANN_VIEW_NEON #undef NLOHMANN_VIEW_SSE2 #undef NLOHMANN_VIEW_SSSE3 diff --git a/include/nlohmann/detail/view/node.hpp b/include/nlohmann/detail/view/node.hpp index 215c4ca9c..d4bfed7ea 100644 --- a/include/nlohmann/detail/view/node.hpp +++ b/include/nlohmann/detail/view/node.hpp @@ -45,7 +45,7 @@ struct node { std::uint8_t kind; ///< value_t std::uint8_t flags; ///< node_flags - std::uint16_t extra; ///< numbers: integer digits (low byte) and fraction digits (high byte), 255 = "many"; objects: number of the hash index (1-based, 0 = none); otherwise 0 + std::uint16_t extra; ///< numbers: integer digits (low byte) and fraction digits (high byte), 255 = "many"; objects: number of the object's key hash index (1-based, 0 = none); otherwise 0 std::uint32_t off; ///< source offset (string content, number token, literal, bracket); arena offset if node_flags::escaped std::uint32_t len; ///< string: decoded bytes; float: token bytes; array/object: element count std::uint32_t next; ///< array/object: number of nodes of the subtree (its extent in the enclosing sequence) diff --git a/include/nlohmann/detail/view/object_index.hpp b/include/nlohmann/detail/view/object_index.hpp index c498d8585..5cdb11079 100644 --- a/include/nlohmann/detail/view/object_index.hpp +++ b/include/nlohmann/detail/view/object_index.hpp @@ -13,11 +13,12 @@ #include // memcmp #include +#include #include #include #include -// Hash indexes of large objects, so that a lookup does not compare thousands +// Hash indexes of the keys of large objects, so that a lookup does not compare thousands // of keys (as Boost.JSON switches from a linear search to a hash table for // large objects). An object with document_data::index_min_members members or // more gets an open-addressing table after parsing; its node stores the @@ -42,6 +43,12 @@ namespace view /// members; and every member costs at most this many steps while building) constexpr std::size_t index_max_displacement = 64; +// a table has at least 2 * len slots (len >= index_min_members), so the probe +// window of index_max_displacement + 1 slots stays far smaller than the table +// and a probe never wraps around onto its own start +static_assert(document_data::index_min_members >= 2 * index_max_displacement, + "a table must be much larger than the probe window of a lookup"); + /// hash of a key: its bytes, eight at a time, in a fixed byte order inline std::uint64_t key_hash(const char* s, std::size_t n) noexcept { @@ -79,43 +86,34 @@ inline void build_object_index(document_data& d, node* obj) d.index_slots.resize(start + cap, 0); std::uint32_t* const table = d.index_slots.data() + start; const std::size_t mask = cap - 1; - bool degenerate = false; for (const node* k = document_data::first_child(obj), *end = document_data::child_end(obj); k != end; k = document_data::after(k + 1)) { const char* const key = d.str(*k); - const std::uint64_t hash = key_hash(key, k->len); // (a cast of the call would be useless where std::uint64_t is std::size_t) - std::size_t i = static_cast(hash) & mask; + std::size_t slot = conditional_static_cast(key_hash(key, k->len)) & mask; bool duplicate = false; std::size_t distance = 0; - while (table[i] != 0) + while (table[slot] != 0) { - const node* const other = obj + table[i]; + const node* const other = obj + table[slot]; if (other->len == k->len && (k->len == 0 || std::memcmp(d.str(*other), key, k->len) == 0)) { duplicate = true; // keep the first break; } - if (++distance > index_max_displacement) + if (distance == index_max_displacement) { - degenerate = true; // too many keys share a home region - break; + // too many keys share a home region + d.index_slots.resize(start); // no table: the object is searched linearly + return; } - i = (i + 1) & mask; - } - if (degenerate) - { - break; + ++distance; + slot = (slot + 1) & mask; } if (!duplicate) { - table[i] = static_cast(k - obj); + table[slot] = static_cast(k - obj); } } - if (degenerate) - { - d.index_slots.resize(start); // no table: the object is searched linearly - return; - } d.indexes.push_back(document_data::object_index{start, static_cast(mask)}); obj->extra = static_cast(d.indexes.size()); } @@ -135,11 +133,10 @@ inline const node* find_indexed(const document_data& d, const node* obj, const c { const document_data::object_index& ix = d.indexes[obj->extra - 1u]; const std::uint32_t* const table = d.index_slots.data() + ix.start; - const std::uint64_t hash = key_hash(key, n); // (a cast of the call would be useless where std::uint64_t is std::size_t) - std::size_t i = static_cast(hash) & ix.mask; + std::size_t slot = conditional_static_cast(key_hash(key, n)) & ix.mask; for (std::size_t distance = 0; distance <= index_max_displacement; ++distance) { - const std::uint32_t s = table[i]; + const std::uint32_t s = table[slot]; if (s == 0) { return nullptr; @@ -149,7 +146,7 @@ inline const node* find_indexed(const document_data& d, const node* obj, const c { return k; } - i = (i + 1) & ix.mask; + slot = (slot + 1) & ix.mask; } return nullptr; // (no key sits further from its home slot) } diff --git a/include/nlohmann/detail/view/scan.hpp b/include/nlohmann/detail/view/scan.hpp index 392925ee1..c6139c8e5 100644 --- a/include/nlohmann/detail/view/scan.hpp +++ b/include/nlohmann/detail/view/scan.hpp @@ -83,14 +83,12 @@ NLOHMANN_VIEW_ALWAYS_INLINE const unsigned char* scan_string_run(const unsigned if (e - p >= 16) { #define NLOHMANN_VIEW_STEP(i) if (NLOHMANN_VIEW_LIKELY(plain[p[i]] != 0)) {} else { p += (i); goto stop; } - NLOHMANN_VIEW_STEP(0) NLOHMANN_VIEW_STEP(1) NLOHMANN_VIEW_STEP(2) NLOHMANN_VIEW_STEP(3) - NLOHMANN_VIEW_STEP(4) NLOHMANN_VIEW_STEP(5) NLOHMANN_VIEW_STEP(6) NLOHMANN_VIEW_STEP(7) + NLOHMANN_VIEW_REPEAT_FIRST_8(NLOHMANN_VIEW_STEP) #if NLOHMANN_VIEW_VECTOR if (!Value) // (a plain `!Value || !NLOHMANN_VIEW_VECTOR` is a constant condition for MSVC, C4127) #endif { - NLOHMANN_VIEW_STEP(8) NLOHMANN_VIEW_STEP(9) NLOHMANN_VIEW_STEP(10) NLOHMANN_VIEW_STEP(11) - NLOHMANN_VIEW_STEP(12) NLOHMANN_VIEW_STEP(13) NLOHMANN_VIEW_STEP(14) NLOHMANN_VIEW_STEP(15) + NLOHMANN_VIEW_REPEAT_SECOND_8(NLOHMANN_VIEW_STEP) p += 8; } #undef NLOHMANN_VIEW_STEP diff --git a/tests/src/unit-json_view_dump.cpp b/tests/src/unit-json_view_dump.cpp index cfe621188..fd04d397f 100644 --- a/tests/src/unit-json_view_dump.cpp +++ b/tests/src/unit-json_view_dump.cpp @@ -366,11 +366,13 @@ TEST_CASE("json_view large objects") std::string text = "{"; std::map first; std::map last; + std::map occurrences; for (const member& m : members) { text += (text.size() > 1 ? ",\"" : "\"") + m.key + "\":" + std::to_string(m.position); first.insert({m.key, m.position}); last[m.key] = m.position; + ++occurrences[m.key]; } text += '}'; REQUIRE(first.size() < members.size()); @@ -389,7 +391,7 @@ TEST_CASE("json_view large objects") CHECK(v.find(entry.first).value().get() == expected); CHECK(v.value(entry.first, std::size_t{0}) == expected); CHECK(v.contains(entry.first)); - CHECK(v.count(entry.first) == 1); + CHECK(v.count(entry.first) == occurrences[entry.first]); const std::string pointer = "/" + entry.first; CHECK(v[json::json_pointer(pointer)].get() == expected); CHECK(v.at(json::json_pointer(pointer)).get() == expected);