Skip to content

Speed up flatten(), unflatten(), diff() and patch() further - #5886

Merged
nlohmann merged 11 commits into
developfrom
perf/value-ops
Oct 23, 2026
Merged

nlohmann merged 11 commits into
developfrom
perf/value-ops

Conversation

@nlohmann

Copy link
Copy Markdown
Owner

A follow-up to #5861.

Instructions, macOS / Linux GCC 13 / Linux Clang 18:

commit change
Resume patch pointers from the previous operation and read operation members in one pass patch canada 85.8 -> 67.7M / 67.4 -> 42.6M / 70.0 -> 43.6M
Scan reference tokens and array indices in place in resolve_plain() patch -10 % (Linux)
Skip the token copy for array indices and the prefix look-up for a first child 0 in unflatten() canada -14 ... -16 %
Reserve the storage of an ordered_json in flatten() once citm -37 %, twitter -34 %
Add the members of a diff operation without looking them up -4 % / -1 % / -9 %
Keep the target values of a diffed object of up to 16 members on the stack citm -7 %
Write the path of an array element in flatten() with a single resize canada -4.3 %
Search once for a new object member in unflatten() and j[json_pointer] twitter -6 % / -13 %
Reserve room for two elements in the arrays unflatten() creates canada -13 %

The first commit also adds an is_dash() helper. GCC turned reference_token == "-" into a strlen and memcmp call: contains() was 15 % slower before the helper and is now unchanged.

Numbers

macOS timing vs develop:

benchmark ratio
FlattenOrdered 0.63
Patch 0.82
Unflatten 0.85
Diff 0.91

No benchmark is slower than +0.6 % (noise).

Validation

  • Differential check against develop, byte-identical, in plain, JSON_DIAGNOSTICS and JSON_DIAGNOSTIC_POSITIONS builds:
    • the corpus, as json and ordered_json;
    • 60,000 random rounds of documents, patches and pointers, valid and invalid;
    • exception ids and messages.
  • 46 unit test runs with ASan/UBSan, in C++11 and C++20.
  • All commits build on their own, and each commit's single_include is a fresh make amalgamate.

Public API

No breaking changes.

Written by Claude Code on behalf of @nlohmann.

🤖 Generated with Claude Code

…members in one pass

A patch from diff() lists its operations in document order, so consecutive
pointers share most of their reference tokens. resolve_plain() now keeps the
values along the pointer it resolved last and resumes from the deepest one
that is also a prefix of the next pointer, instead of walking every pointer
from the root. The values stay valid because a plain operation only changes
the target (replace) or its parent (add, remove), and the general operations
reset the cache. The "~" check covers only the part of the pointer that is
resolved anew.

The members "op", "path", "value", and "from" of an operation are found in
one pass over its object instead of up to four lookups (for key types other
than string_t, by lookup).

patch() on the diff of every eighth scalar (instructions on macOS, Linux Ir GCC 13 / Clang 18):
canada 85.8M -> 67.7M, 67.4M -> 42.6M / 70.0M -> 43.6M; citm_catalog 28.7M ->
27.1M, 20.7M -> 18.1M / 20.7M -> 18.0M; twitter 14.3M -> 13.2M, 11.4M -> 9.5M
/ 11.3M -> 9.4M. Exceptions and results are unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Find the end of a reference token and reject an escape in one loop over the
characters instead of two library calls (find_first_of, memchr), and parse an
array index straight from the pointer instead of copying the token first. An
index that is not a plain number of up to 18 digits (9 for a 32-bit
size_type) is left to json_pointer, which reports it as before.

patch() on the diff of every eighth scalar (instructions on macOS, Linux Ir GCC 13 /
Clang 18): canada 67.7M -> 63.7M, 42.6M -> 38.4M / 43.6M -> 40.0M; citm_catalog
27.1M -> 26.8M, 18.1M -> 17.7M / 18.0M -> 17.7M; twitter unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…rst child 0 in unflatten()

Array elements are addressed by a plain index of up to 9 digits in nearly
all flattened documents. Parse it straight from the key instead of copying
the token into a string and converting that. A new container whose first
child has the token 0 is an array by definition, so the hash set of array
prefixes is only consulted for other first children.

unflatten() (instructions on macOS, Linux Ir GCC 13 / Clang 18): canada 188.4M ->
161.1M, 151.3M -> 128.1M / 162.3M -> 138.0M; citm_catalog 45.0M -> 43.3M, 40.3M
-> 38.9M / 41.1M -> 39.6M; twitter unchanged. Output and exceptions are
identical to develop over the differential check.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
flatten() appended the entries to an ordered_map one by one, so its vector
grew by doubling and moved every entry about once in total. The number of
entries is known after a walk over the value that only counts (it needs no
path strings), so objects with a reserve() are given that capacity first.
std::map has no reserve() and is unchanged.

flatten() of an ordered_json (instructions on macOS, Linux Ir GCC 13 / Clang 18):
citm_catalog 36.8M -> 23.1M, 32.7M -> 17.1M / 31.7M -> 16.8M; twitter 20.5M ->
13.6M, 17.6M -> 10.0M / 17.0M -> 9.8M. Output is identical.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff_operation() created each operation through three operator[] calls and
assignments. The keys "op", "path" and "value" are distinct and already in
the order of std::map, so they are added with the positional insert that
flatten() uses, constructing the values in place.

diff() (instructions on macOS, Linux Ir GCC 13 / Clang 18): canada 85.3M -> 82.0M,
51.1M -> 50.7M / 55.3M -> 50.2M; citm_catalog 22.0M -> 21.5M, 14.3M -> 14.2M /
14.9M -> 14.2M; twitter 12.4M -> 12.0M, 7.5M unchanged / 8.0M -> 7.5M.
Output is identical.

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

diff_unequal() allocated a vector with the values in target of the members
of every object it diffed member by member. Objects with up to 16 members,
which are most of them, now use space in the stack frame, and larger ones
a vector as before. The storage is chosen once, when its size is reserved,
so that adding and reading a value needs no check of which one is in use.

diff() over the previous commit (instructions on macOS, Linux Ir GCC 13 / Clang
18): citm_catalog 21.5M -> 20.0M, 14.2M -> 13.5M / 14.2M -> 13.7M; twitter
12.0M -> 11.7M, 7.5M -> 7.3M / 7.5M -> 7.4M; canada 82.0M -> 81.5M.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Replacing the path of the previous child by "/<index>" took a resize, a
push_back and an append. For array elements the path is now resized once
to its final length and the slash and the digits are written into it.
Object keys keep the three calls: a version that also wrote them directly
made flatten() of twitter 4 % slower in cycles at equal instructions.

flatten() (instructions on macOS, Linux Ir GCC 13 / Clang 18): canada 172.1M ->
164.7M, 123.8M -> 120.6M / 121.2M -> 118.9M; citm_catalog 40.4M -> 39.5M, 30.1M
-> 29.6M / 30.0M -> 29.6M; twitter unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
object_member() looked a key up and, if it was missing, inserted it with
operator[], which searches again. Objects that can find the position of a
key and insert there (std::map) now do both with one search; other object
types are unchanged. A key that exists is not copied, as before.

unflatten() (instructions on macOS, Linux Ir GCC 13 / Clang 18): twitter 25.8M ->
24.2M, 24.9M -> 21.8M / 24.8M -> 21.5M; citm_catalog 43.4M -> 43.5M, 38.9M ->
37.0M / 39.6M -> 37.5M; canada unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
An array that unflatten() creates grows by one element at a time, so an
array of two elements (a coordinate pair, a range) was allocated, grown and
freed once more than needed. New arrays get room for two elements first,
for array types that have reserve(). Arrays of two or more elements need no
more memory than before; an array of one element has room for one more.

unflatten() (instructions on macOS, Linux Ir GCC 13 / Clang 18): canada 161.9M ->
141.2M, 128.3M -> 117.9M / 138.0M -> 128.6M; citm_catalog 43.5M -> 42.8M, 37.0M
-> 36.8M / 37.5M -> 37.3M; twitter unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann nlohmann added review needed It would be great if someone could review the proposed changes. performance labels Oct 22, 2026
Comment thread include/nlohmann/detail/json_pointer.hpp
Comment thread single_include/nlohmann/json.hpp
<windows.h> (rpcndr.h) defines small as char, so test-windows_h did not
compile with MSVC.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@github-actions github-actions Bot added the L label Oct 22, 2026
- diff_target_values holds its inline values in a std::array (a type
  with an empty user-provided constructor, as the serializer's buffers,
  so that -Weffc++ is met and they stay uninitialized) and declares its
  move members as deleted
- auto for an iterator and a cast, and parentheses around a %

json::diff() instructions are unchanged within noise (Apple clang,
twitter and citm_catalog).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 22, 2026
@nlohmann nlohmann added 🚀 ready to merge Ready to merge - just waiting for CI to complete. and removed review needed It would be great if someone could review the proposed changes. labels Oct 22, 2026
@nlohmann
nlohmann merged commit 86d5c56 into develop Oct 23, 2026
170 of 171 checks passed
@nlohmann
nlohmann deleted the perf/value-ops branch October 23, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L performance 🚀 ready to merge Ready to merge - just waiting for CI to complete.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants