Skip to content

Scan json_view strings with SIMD and index large objects - #5628

Open
nlohmann wants to merge 23 commits into
json-view/13-view-dumpfrom
json-view/16-view-simd
Open

nlohmann wants to merge 23 commits into
json-view/13-view-dumpfrom
json-view/16-view-simd

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Scans string bytes with NEON/SSE2, validates non-ASCII text with simdjson's vector UTF-8 check, and adds a hash index for large objects to the view's parser; part of the zero-copy view stack for #5295, on top of #5624.

Summary

SIMD scanning

  • Long runs of string bytes are scanned 16 bytes at a time with NEON (AArch64, GCC and Clang) and SSE2 (x86-64). Both belong to the baseline instruction sets. SSE2 and <cpuid.h> are only used on x86 targets, so other targets that define __SSE2__ (such as Emscripten) use the portable code.
  • Keys and values: 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.
  • Strings vector-first on x86-64: with SSE2, string runs are checked 16 bytes at a time from their first byte. One compare finds the end of most keys and short values, which is faster than a branch per byte. AArch64 keeps the byte-wise steps: a NEON mask costs more there and the branches predict well.
  • Non-ASCII text is validated 16 bytes at a time with simdjson's "lookup4" check (Keiser and Lemire, 2021), with NEON on AArch64 and with SSSE3 on x86-64.
    • SSSE3 is not part of baseline x86-64. The check is compiled for SSSE3 with a function attribute (GCC 4.9 and later, Clang; MSVC compiles the intrinsics anyway) and used where CPUID reports SSSE3, which all x86-64 CPUs since about 2011 do.
    • The answer is cached in a std::atomic<int> initialized at compile time, so there is no guard of a local static and no global constructor (-Wglobal-constructors). The definitions do not depend on compiler flags, so translation units with different flags cannot violate the ODR.
    • JSON_VIEW_USE_SSSE3 only skips the CPU check; the same input is accepted either way.
  • Opt-out: JSON_VIEW_NO_SIMD selects the portable code.
  • No stall when entering or leaving an object or array: open() stores the parent's frame field by field instead of building it on the stack and copying it. The copy was read back by loads wider than its stores, which waited until the stores retired (failed store forwarding). Apple M1 forwards such stores and does not change.
  • GCC modules: GCC ignores the target attribute in modules, so src/modules/json.cppm defines NLOHMANN_VIEW_NO_TARGET_ATTRIBUTE there; the check stays portable and the SSE2 code stays on.
  • Credit: simdjson is credited in the SPDX block of simd.hpp, in the README and in license.md.

Object index

Lookups in objects are linear, as for ordered_json. Objects with 128 members or more now get a hash table after parsing (detail/view/object_index.hpp):

  • open addressing; of duplicate keys, the first is kept, as for the linear search;
  • operator[], at(), find(), contains(), count(), value() and JSON pointers take constant time on average in such objects;
  • probe cap of 64: a key may sit at most 64 slots from its home slot, and a lookup visits at most 65 slots. The hash is not seeded; if crafted keys would exceed the cap, the table is dropped and that object is searched linearly, so parsing cannot become quadratic;
  • the parser notes a large object when it closes it, out of line, so the parse loop only has a call for it (inline, it made canada.json 37% slower). The object node stores the number of its table in extra;
  • shrink_to_fit() trims the spare capacity of the index tables, memory_usage() counts them, and the list of noted objects is released after parsing;
  • the idea of switching to a hash table for large objects comes from Boost.JSON.

Docs, build files and other touched areas

  • New pages JSON_VIEW_NO_SIMD and JSON_VIEW_USE_SSSE3; the architecture page describes the tables and what extra holds for objects; the lookup pages and the "Duplicate keys" section note that the index leads to the first member.
  • read() notes that reusing a document avoids the page faults of a fresh node index (about 40% of the parse time of a 55 MB document on x86-64 Linux).
  • include/nlohmann/json_view.hpp, BUILD.bazel, src/modules/json.cppm, tests/CMakeLists.txt (the builder tests run again with JSON_VIEW_NO_SIMD, and on x86-64 with JSON_VIEW_USE_SSSE3 -mssse3), README.md, docs/mkdocs/docs/home/license.md, .gitignore (GCC module cache).

Performance

Measured on Apple M1 Max (Apple clang -O2) and an x86-64 KVM VPS (GCC 13 / Clang 18 -O2, pinned to one core, about ±5-10% noise), µs, best of 3 interleaved rounds of 15 runs; files from nativejson-benchmark.

json_document::parse, previous PR → this PR:

Apple M1 x86-64 GCC x86-64 Clang
twitter 245 → 187 (-24%) 560 → 333 (-41%) 529 → 357 (-33%)
citm_catalog 389 → 374 (-4%) 967 → 786 (-19%) 916 → 816 (-11%)
canada 1019 → 1027 (+1%) 1972 → 1827 (-7%) 2282 → 2028 (-11%)

Object index: looking up every key of an object with 10,000 members takes 59.8 ms linearly and 0.16 ms with the index. Parsing stays within noise for documents without large objects.

The core library (json::parse / dump) is unaffected: the changes are inside include/nlohmann/detail/view/ and json_view.hpp, plus the module file and build files; no file the core lexer or serializer uses is touched.

Tests

  • Every two-byte sequence, and three- and four-byte sequences with continuation bytes at the edges of their ranges, each placed at every offset around the vector blocks of keys and values, and also cut short; long runs of text with a damaged byte. All are compared with json::accept and json::parse.
  • CMake builds the builder tests again with JSON_VIEW_NO_SIMD, and on x86-64 with JSON_VIEW_USE_SSSE3 -mssse3.
  • Large objects: 127, 128, 129 and 10,000 members; duplicate keys (the first wins, with and without a table); escaped, empty and missing keys; colliding keys, which get no table and are searched linearly; shrink_to_fit() releasing the tables' spare capacity; nested large objects, objects in arrays, and documents reused with read().

Public API

No breaking changes. The view API is new in this stack, so nothing here changes a released interface.

  • Two new configuration macros, documented under Macros: JSON_VIEW_NO_SIMD (portable scanning) and JSON_VIEW_USE_SSSE3 (skip the CPU check; without it, x86-64 builds use SSSE3 when the CPU has it, and the same inputs are accepted or rejected either way). JSON_VIEW_USE_SSSE3 must be defined identically in every translation unit that includes the header.
  • Lookups in large objects are faster and return the same member as before (the first of duplicate keys); no new members.

Written by Claude Code.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5636 September 29, 2026 14:20
@nlohmann nlohmann changed the title json view/16 view simd Scan the strings of json_view with NEON and SSE2 Sep 29, 2026
@github-actions

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated and/or formatted correctly, or BUILD.bazel is out of date.

@nlohmann
nlohmann removed this pull request from stack #5636 September 30, 2026 13:19
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from 50f0a1b to d33586c Compare September 30, 2026 13:20
@nlohmann
nlohmann added this pull request to stack #5739 September 30, 2026 13:21
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch 3 times, most recently from da1c822 to d4ac1b7 Compare September 30, 2026 18:06
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 30, 2026
@nlohmann
nlohmann marked this pull request as ready for review September 30, 2026 18:18
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from d4ac1b7 to 1f0c3be Compare September 30, 2026 19:19
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann
nlohmann changed the base branch from json-view/15-view-bench to json-view/13-view-dump October 6, 2026 09:26
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from e4f4c76 to 6ee5e89 Compare October 6, 2026 09:28
@nlohmann nlohmann changed the title Scan the strings of json_view with NEON and SSE2 Scan json_view strings with SIMD and index large objects Oct 6, 2026
@nlohmann
nlohmann added this pull request to stack #5768 October 6, 2026 09:29
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from 6ee5e89 to 8daec2b Compare October 7, 2026 14:44
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from 71985e0 to a434c52 Compare October 18, 2026 04:25
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that this is for keys?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: "get a hash index of their keys" in all six pages (a23d11d). In count.md it now says that the index doesn't help count(), which visits every member since #5622.

— posted by Claude Code on behalf of @nlohmann

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that this is for keys?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a23d11d.

— posted by Claude Code on behalf of @nlohmann

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that this is for keys?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count() counts every member with the key now (#5622), so the index doesn't help it; the page says that instead.

— posted by Claude Code on behalf of @nlohmann

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that this is for keys?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a23d11d.

— posted by Claude Code on behalf of @nlohmann

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify that this is for keys?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a23d11d.

— posted by Claude Code on behalf of @nlohmann

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a conditional cast for that

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, conditional_static_cast it is (a23d11d).

— posted by Claude Code on behalf of @nlohmann

duplicate = true; // keep the first
break;
}
if (++distance > index_max_displacement)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't need to combine the increment and the test, and don't need a flag, just return.

if (distance == index_max_displacement)
{
    d.index_slots.resize(start); // no table: the object is searched linearly
    return;
}
++distance;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in a23d11d; it gives up at the same collision as before, without the flag.

— posted by Claude Code on behalf of @nlohmann

d.index_slots.resize(start); // no table: the object is searched linearly
return;
}
d.indexes.push_back(document_data::object_index{start, static_cast<std::uint32_t>(mask)});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emplace_back

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as in the builder: object_index is an aggregate, so emplace_back(start, mask) doesn't compile before C++20.

— posted by Claude Code on behalf of @nlohmann

{
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conditional cast

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done here too (a23d11d).

— posted by Claude Code on behalf of @nlohmann

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<std::size_t>(hash) & ix.mask;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use hash_index and leave i for the for loop index?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to slot in a23d11d: it is a position in the table, and hash_index would read like the name of the whole table.

— posted by Claude Code on behalf of @nlohmann

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a Qt collision? (I know having to deal with such broad Qt macros is not pleasant.)

@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch 2 times, most recently from 6666f55 to 4cdc02d Compare October 18, 2026 08:38
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>
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>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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>
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>
WebAssembly with -msse2 defines __SSE2__, but has no <cpuid.h> or x86 intrinsics headers.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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>
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 a695420; 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>
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>
…uilder.cpp

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
….cpp

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…in32)

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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>
…ump.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>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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>
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>
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>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
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>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the json-view/16-view-simd branch from adaa127 to 586fab9 Compare October 18, 2026 11:41
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CMake documentation L review needed It would be great if someone could review the proposed changes. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants