Skip to content

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

Merged
nlohmann merged 8 commits into
developfrom
perf/value-operations-speedup
Oct 21, 2026
Merged

nlohmann merged 8 commits into
developfrom
perf/value-operations-speedup

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

flatten(), unflatten() and diff() spent most of their time on work that can be avoided: searching a std::map from the root for every flattened path, splitting every key into a vector of strings and walking it from the root again, and building every diff operation from initializer lists. This makes them 1.4× to 2.3× as fast, and patch() a little faster. Output, exceptions and the state left after a failing patch are unchanged.

One commit per change:

  1. flatten: for object types with emplace_hint (std::map), each path is inserted next to the entry added last, since paths come out in nearly sorted order. ordered_map keeps its append without lookup; other object types keep operator[]. Path segments are appended in place.
  2. unflatten: keys are checked by a scan that throws the same parse_error.107/108 as split(), skipping the part shared with the previous key. The prefixes that become arrays are kept in a small hash set of key slices instead of a prefix tree, and the walk keeps the values along the previous key's path, so only the part of a key that differs from the previous one is looked up. The array/object decision is unchanged.
  3. patch: add, remove, replace and test on a path without escapes that denotes existing values (as diff() creates them) are applied without building a json_pointer. Everything else, and every operation that would fail, takes the existing code.
  4. diff: operations are built member by member instead of from initializer lists (about 25 allocations each before), and paths are built in place.
  5. diff: objects with the same keys in the same order are walked together without lookups, and the path is one buffer that is extended and shortened again.

Performance

tests/benchmarks of #5845, Apple M1 Max, Apple Clang 21, -O3 -flto, C++11, medians of 4 interleaved rounds against develop (5683f5e):

benchmark develop this PR speedup
Flatten/canada 22.09 ms 11.67 ms 1.89×
Flatten/citm_catalog 4.84 ms 3.03 ms 1.60×
Flatten/twitter 2.87 ms 2.03 ms 1.41×
FlattenOrdered/citm_catalog 2.37 ms 2.22 ms 1.07×
FlattenOrdered/twitter 1.49 ms 1.42 ms 1.05×
Unflatten/canada 33.34 ms 15.61 ms 2.14×
Unflatten/citm_catalog 8.27 ms 3.68 ms 2.25×
Unflatten/twitter 4.38 ms 2.25 ms 1.94×
Diff/canada 7.83 ms 4.02 ms 1.95×
Diff/citm_catalog 1.88 ms 1.25 ms 1.51×
Diff/twitter 1.24 ms 0.71 ms 1.75×
Patch/canada 5.46 ms 4.99 ms 1.10×
Patch/citm_catalog 1.85 ms 1.77 ms 1.04×
Patch/twitter 1.06 ms 0.98 ms 1.09×

patch() is bound by the copy of the document it makes (3.4 of 5.5 ms on canada). For ordered_json, unflatten() is 2.05× to 2.2× and diff() 1.65× to 1.85× as fast; patch() 1.03× to 1.09×.

Breaking changes

None. Only private and detail code changes; the public API, the output and the exceptions are unchanged. The private helpers json_pointer::prefix_tree and json_pointer::get_and_create are removed, and basic_json::diff_recursively/diff_unequal take the path as a mutable buffer; none of them is part of the public API.

Testing

  • A differential check against develop on canada, citm_catalog and twitter, for json and ordered_json: flatten(), unflatten(), diff() at three change densities and patch(), plus about 90,000 random flattened objects (valid and invalid keys), 200,000 random patches (including the partially patched state after a failing one) and random diff/patch and flatten/unflatten round trips. 137 MB of output, byte-identical, including exception ids and messages.
  • Unit tests: json_pointer, json_patch, ordered_json, ordered_json2, ordered_map, alt-string, custom-array-type, custom-object-type, custom-key-type, diagnostics, diagnostic-positions, merge_patch, merge_diff, same-number-types, readme, throw_messages, regression2, allocator, constructor1, pointer_access, class_const_iterator; json_patch, json_pointer and ordered_json also with JSON_DIAGNOSTICS=1.
  • Every commit compiles without warnings with GCC 16 and Clang (C++11, -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wold-style-cast -Werror), and its single_include matches make amalgamate.

This PR was written by Claude Code on behalf of @nlohmann.

🤖 Generated with Claude Code

Comment thread include/nlohmann/detail/json_pointer.hpp Dismissed
Comment thread include/nlohmann/detail/json_pointer.hpp Fixed
Comment thread include/nlohmann/detail/json_pointer.hpp Dismissed
Comment thread include/nlohmann/detail/json_pointer.hpp Dismissed
Comment thread include/nlohmann/detail/json_pointer.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Dismissed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Dismissed
Comment thread single_include/nlohmann/json.hpp Dismissed
Comment thread single_include/nlohmann/json.hpp Fixed
@github-actions github-actions Bot added the L label Oct 16, 2026
/// append the decimal representation of @a i to @a out
static void append_index(string_t& out, std::size_t i)
{
char buf[24]; // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays)

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.

std::array?

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, done in 8a893ca.

— posted by Claude Code on behalf of @nlohmann

Comment thread include/nlohmann/json.hpp Outdated
target_values.reserve(source.size());
std::size_t common = 0;
bool together = source.size() == target.size();
auto partner = target.cbegin();

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.

This partner should be able to go in the for loop initializer as well.

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, done in a3eea58.

— posted by Claude Code on behalf of @nlohmann

@nlohmann
nlohmann force-pushed the perf/value-operations-speedup branch 2 times, most recently from 2241af3 to 65fbbc5 Compare October 17, 2026 06:29
std::uint64_t w = 0;
if (i < size)
{
std::memcpy(&w, data + i, size - i);
std::uint64_t w = 0;
if (i < size)
{
std::memcpy(&w, data + i, size - i);
flatten() added every path with operator[], so each insertion into the
std::map of the result searched the tree from the root and compared long
shared path prefixes, which took half the time on canada.json. Paths come
out in nearly sorted order, so they are now inserted with emplace_hint
next to the entry added last, for object types that have it. ordered_map
keeps its append without lookup, and other object types keep
operator[].

Path segments are also appended in place, without a temporary string
for each escaped key or array index.

flatten() of canada.json, citm_catalog.json and twitter.json is 1.4x to
1.9x as fast; ordered_json is unchanged (1.05x). The output is
unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
unflatten() split every key into a vector of reference tokens, stored the
pointer prefixes in a tree to decide which values become arrays, and then
looked up every token of every key from the root again.

Keys are now checked by a scan that raises the same parse_error.107/108 as
split(), skipping the part a key shares with the previous one, which was
checked already. The prefixes that denote arrays are kept in a small hash
set of key slices; the array/object decision is unchanged. The walk keeps
the values along the previous key's path, so only the part of a key that
differs from the previous one is looked up. Keys of a flattened object are
usually sorted, so that part is short.

unflatten() of canada.json, citm_catalog.json and twitter.json is 1.9x to
2.3x as fast; the output and the exceptions are unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
patch() built a json_pointer for every operation, which splits the path
into a vector of strings, and for "add" and "remove" a second one for
the parent. That took 20% to 45% of the time not spent copying the
document.

"add", "remove", "replace" and "test" with a path that has no escapes and
denotes existing values (the parent, for "add"), as diff() creates them,
are now applied by resolving the path in place, reusing one string for
the reference tokens. Every other operation, and every operation that
would fail, takes the existing code, so the results, the exceptions and
the state left after a failing operation are unchanged.

patch() of the diffs of canada.json, citm_catalog.json and twitter.json
is 1.02x to 1.10x as fast; the rest is the copy of the document.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff() created every operation from an initializer list, so each
{"op", "replace"} pair became a temporary array with two heap strings
before it was turned into an object member; an operation took about 25
allocations. The operations are now built as objects whose members are
assigned one by one. Paths of members and elements are also built in
place, without a temporary string for each escaped key or array index.

diff() of canada.json, citm_catalog.json and twitter.json, with every
eighth scalar changed, is 1.3x to 1.6x as fast; the output is unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff() looked up every key of the source object in the target object,
and built a new string for the path of every value it descended into.
Objects of the same size usually have the same keys in the same order,
so they are now walked together as long as that holds, without a
lookup; objects that differ fall back to the lookup from the first key
that does not match. The path is one buffer that is extended before
descending and shortened again afterwards. Objects whose members are
equal but in a different order (possible with ordered_json) still get
no operations.

Together with the previous commit, diff() of canada.json,
citm_catalog.json and twitter.json is 1.5x to 2.0x as fast; ordered_json
1.65x to 1.85x. The output is unchanged.

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

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the perf/value-operations-speedup branch from 65fbbc5 to b3fd84d Compare October 20, 2026 05:51
@nlohmann nlohmann added review needed It would be great if someone could review the proposed changes. performance labels Oct 21, 2026
@nlohmann nlohmann removed the review needed It would be great if someone could review the proposed changes. label Oct 21, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 21, 2026
@nlohmann
nlohmann merged commit 9bf5314 into develop Oct 21, 2026
170 checks passed
@nlohmann
nlohmann deleted the perf/value-operations-speedup branch October 21, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants