Skip to content

Speed up container destruction, iterator access and diff() - #5835

Merged
nlohmann merged 2 commits into
perf/out-of-line-throwsfrom
perf/destroy-iterators-diff
Oct 15, 2026
Merged

nlohmann merged 2 commits into
perf/out-of-line-throwsfrom
perf/destroy-iterators-diff

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Three API-neutral speedups found in a performance survey of the non-parse API.

  1. Destroy fast path: a freshly entered container whose children are all leaves is freed in one go (all_children_leaves); the walk stays non-recursive and allocation-free (tested with ulimit -s 512 on 1M–2M deep nesting). Object of 100k ints: 41.8 → 17.4 ns/member (std::map dtor itself: 15 ns); flat array of 1M doubles 2.1 → 1.8 ns; documents unchanged.
  2. iter_impl fast paths: array, then object, are checked before the type switch in ++, -- (prefix and postfix), *, -> (arrays and objects) and ==; <, += and - stay on the switch; the cold throw in == moved to a NEVER_INLINE member. Range-for over an array 2.5 → 1.3 ns/element, items() over an array 2.2 → 1.0, range-for over an object 4.9 → 4.1.
  3. diff(): one find per source key (storing vector<const basic_json*>), target pass skipped when no keys were added, "add" operations built lazily, children compared with == before their path is built; same in the iterative (deep) path. diff(a, a + 1% changes): citm 1.09 → 0.70 ms, twitter 0.92 → 0.41 ms; diff({}, citm) 3.18 → 1.62 ms.

Reverse range iteration (rbegin()..rend(), Apple clang -O2 -DNDEBUG, best of 15): over a 1M-element array develop 3.5 ns/element, PR before this update 1.9, after 1.9 (the -- fast path adds nothing measurable there, the reverse iterator was already well inlined); over a 200k-member object develop 8.2, before 6.2, after 6.5 (within noise).

Tests added: reverse iteration (--, rbegin/rend) and object -> for json and ordered_json, comparing iterators of different containers (unit-iterators1); diff() round trips with reordered keys and nested add/remove/replace for both types (unit-json_patch); destruction of empty, mixed and 5000-deep containers for both types (unit-constructor2). Run with -fsanitize=address,undefined, also with -DJSON_DIAGNOSTICS=1 for diagnostics.

Verification: differential fuzz of diff (40k random pairs, json and ordered_json, keys with ~ and /, reordered keys, deep nesting through the iterative path) is byte-identical to develop and patch(diff) round-trips. Unit tests iterators1/2, items, class_iterator, class_const_iterator, json_patch, regression2, class_parser, allocator, ordered_json/ordered_map, custom-object-key-type, diagnostics, merge_patch pass, also with -DJSON_DIAGNOSTICS=1.

Breaking changes

None. No public interface, ABI or layout change; diff() output is identical.

🤖 Generated with Claude Code

{
JSON_ASSERT(m_object != nullptr);

if (JSON_HEDLEY_LIKELY(m_object->m_data.m_type == value_t::array))

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.

Why duplicate the code from the switch here? Was this supposed to be a move? If so, why?

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.

Written by Claude Code on behalf of @nlohmann. You're right, the duplication was unintended: it is now a move (commit 7cc6f32). The array/object branches sit in front of the switch so the hot path inlines (range-for over an array 2.5 → 1.3 ns/element); the switch bodies for those cases are gone, and what remained only handled primitives, so it is now plain code. No behaviour change.

{
JSON_ASSERT(m_object != nullptr);

if (JSON_HEDLEY_LIKELY(m_object->m_data.m_type == value_t::array))

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.

Why duplicate the code from the switch here? Was this supposed to be a move? If so, why?

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.

Written by Claude Code on behalf of @nlohmann. You're right, the duplication was unintended: it is now a move (commit 7cc6f32). The array/object branches sit in front of the switch so the hot path inlines (range-for over an array 2.5 → 1.3 ns/element); the switch bodies for those cases are gone, and what remained only handled primitives, so it is now plain code. No behaviour change.

{
JSON_ASSERT(m_object != nullptr);

if (JSON_HEDLEY_LIKELY(m_object->m_data.m_type == value_t::array))

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.

Why duplicate the code from the switch here? Was this supposed to be a move? If so, why?

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.

Written by Claude Code on behalf of @nlohmann. You're right, the duplication was unintended: it is now a move (commit 7cc6f32). The array/object branches sit in front of the switch so the hot path inlines (range-for over an array 2.5 → 1.3 ns/element); the switch bodies for those cases are gone, and what remained only handled primitives, so it is now plain code. No behaviour change.

return true;
}

if (JSON_HEDLEY_LIKELY(m_object->m_data.m_type == value_t::array))

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.

Why duplicate the code from the switch here? Was this supposed to be a move? If so, why?

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.

Written by Claude Code on behalf of @nlohmann. You're right, the duplication was unintended: it is now a move (commit 7cc6f32). The array/object branches sit in front of the switch so the hot path inlines (range-for over an array 2.5 → 1.3 ns/element); the switch bodies for those cases are gone, and what remained only handled primitives, so it is now plain code. No behaviour change.

@nlohmann

Copy link
Copy Markdown
Owner Author

Written by Claude Code on behalf of @nlohmann. Pushed f4fd1e1: operator-- (prefix; postfix goes through it) and object operator-> now have array/object fast paths like ++; <, +=, - and the throws are untouched. Fixed the free_container comment (the container is empty or holds only leaves) and added a weaker assert for that. New tests in unit-iterators1 (reverse iteration, object ->, cross-container comparison), unit-json_patch (diff round trips, reordered ordered_json) and unit-constructor2 (destruction, 5000-deep) pass under ASan/UBSan, also with JSON_DIAGNOSTICS. Reverse iteration over 1M array elements: develop 3.5 ns, previous head 1.9, now 1.9 (no further gain measurable).

@nlohmann
nlohmann force-pushed the perf/destroy-iterators-diff branch from 7cc6f32 to af4e6e2 Compare October 15, 2026 06:24
@nlohmann
nlohmann changed the base branch from develop to perf/out-of-line-throws October 15, 2026 06:24
@nlohmann
nlohmann added this pull request to stack #5841 October 15, 2026 06:24
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 15, 2026
@nlohmann nlohmann added the 🚀 ready to merge Ready to merge - just waiting for CI to complete. label Oct 15, 2026
@nlohmann
nlohmann force-pushed the perf/destroy-iterators-diff branch from af4e6e2 to b115675 Compare October 15, 2026 08:57
@nlohmann
nlohmann force-pushed the perf/destroy-iterators-diff branch 2 times, most recently from 82250cb to 99ae8b9 Compare October 15, 2026 17:41
@nlohmann
nlohmann force-pushed the perf/destroy-iterators-diff branch 2 times, most recently from 47e0c77 to 0eb415c Compare October 15, 2026 17:45
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
The pointer-reversing walk in destroy_container() avoids the call stack but
moves a value around for every container it visits. Recurse for the first
recursion_depth_limit() levels instead - no allocation, no thread-local, a
bounded frame - and hand anything deeper to the walk. Destroy twitter
0.49 -> 0.36 ms, citm 0.80 -> 0.74 ms (3.12.0: 0.47 / 1.00).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the perf/destroy-iterators-diff branch from 0eb415c to 8aeb14e Compare October 15, 2026 17:54
@nlohmann
nlohmann merged commit 4f11512 into develop Oct 15, 2026
3 of 165 checks passed
@nlohmann
nlohmann deleted the perf/destroy-iterators-diff branch October 15, 2026 18:01
nlohmann added a commit that referenced this pull request Oct 15, 2026
The comment block about destroying the tree without recursion was indented
one level too deep since #5835. make amalgamate reformatted it with astyle,
leaving an unrelated diff in every branch. single_include is unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann added a commit that referenced this pull request Oct 16, 2026
- unit-iterators1 (#5835) converted a json element to int implicitly,
  which does not compile with JSON_USE_IMPLICIT_CONVERSIONS=0.
- unit-testsuites parses a number followed by a NUL byte, which
  JSON_STRICT_NUL_HANDLING rejects; leave the NUL out in strict mode.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

2 participants