Skip to content

Fix quadratic parent updates for ordered_json with JSON_DIAGNOSTICS - #5832

Merged
nlohmann merged 2 commits into
perf/to-json-rvaluefrom
fix/diagnostics-ordered-json-quadratic
Oct 15, 2026
Merged

nlohmann merged 2 commits into
perf/to-json-rvaluefrom
fix/diagnostics-ordered-json-quadratic

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

With JSON_DIAGNOSTICS=1, set_parent() called the full set_parents() for ordered_json on every call: push_back/emplace_back, at(), and operator[]/at() on existing keys. Building an ordered_json array was therefore quadratic.

Changes

  • at() and array append set only the one child's m_parent.
  • Object operator[]/push_back/emplace record the container capacity before the insert and call set_parents() only if it changed. Arrays and objects share one container_capacity() and one set_parent_after_insert() helper (arrays report their capacity(); for objects, only the node-based standard containers (std::map, std::multimap, std::unordered_map, std::unordered_multimap) report 0 = never moves, only nlohmann::ordered_map reports its capacity(), and any other object type reports unknown = always re-parent).
  • update() (all overloads, incl. merge_objects and the iterative variant) now assigns through operator[] instead of calling set_parent() per key; merge_patch() already goes through operator[], so it benefits too. erase keeps its set_parents_after_object_erase() (O(n) for ordered_map anyway).
  • Remove the redundant set_parents() in the copy constructor (copy_level/copy_iteratively already re-parent each level). The move constructor keeps its set_parents() (required).
  • New tests: parent-pointer invariant checker (json and ordered_json) across parse, copy, move, copy-assign, swap, push_back/emplace_back/emplace/operator[] growth and at(); update() (both overloads, merge_objects) and merge_patch() on json/ordered_json checking parents and diagnostic paths. No timing test (would be flaky).

Numbers (ordered_json, nested-array push_back, -O2, diagnostics on)

n before after
25k 101 ms ~8 ms
50k 412 ms ~5 ms
100k 1635 ms ~11 ms
200k 6482 ms 27 ms

ordered_json::update() with 20k new keys: 372 → 295 ms (no diagnostics: 259 ms), i.e. the diagnostics overhead is gone; the rest is ordered_map's inherent linear key lookup (100k keys: 14.0 → 10.0 s).

at(i) x10000: 31.7 → 0.017 ms. Parse unchanged; copies −5–8 % with diagnostics.

Breaking changes

None. No public API change; behaviour without JSON_DIAGNOSTICS is unchanged.

Tested with -DJSON_DIAGNOSTICS=1: unit-diagnostics, unit-ordered_json, unit-element_access1/2, unit-modifiers. make amalgamate run.

@nlohmann

Copy link
Copy Markdown
Owner Author

Reworked in c1d3493 (written by Claude Code, on Niels's behalf):

  • Missed site fixed: update_members and update_members_iteratively called set_parent() per key, which re-parented the whole ordered_json each time. They now assign through operator[], which re-parents everything only when the vector reallocated. merge_patch() already used operator[], so it is covered. I found no other per-element set_parent( loops over objects or arrays.
  • Deduplicated: array_capacity/object_capacity are now one container_capacity(), and the two after-insert helpers are one set_parent_after_insert(). set_parent_plain and set_parent_at are merged.
  • Tests: update() (both overloads, with merge_objects) and merge_patch() on json and ordered_json, checking parent pointers and diagnostic paths.
  • Timing: ordered_json::update() of 20k keys with diagnostics goes from 372 to 295 ms; without diagnostics it takes 259 ms. 100k keys goes from 14.0 to 10.0 s. What remains is the linear key lookup of ordered_map, not the parent updates.

unit-diagnostics passes. unit-modifiers and unit-ordered_json pass with and without JSON_DIAGNOSTICS.

@nlohmann
nlohmann force-pushed the fix/diagnostics-ordered-json-quadratic branch from c1d3493 to 1e18938 Compare October 15, 2026 06:24
@nlohmann
nlohmann changed the base branch from develop to perf/to-json-rvalue October 15, 2026 06:24
@nlohmann
nlohmann added this pull request to stack #5841 October 15, 2026 06:24
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Oct 15, 2026
@nlohmann
nlohmann force-pushed the fix/diagnostics-ordered-json-quadratic branch from 1e18938 to 8516093 Compare October 15, 2026 08:56
@nlohmann
nlohmann force-pushed the fix/diagnostics-ordered-json-quadratic branch from 8516093 to ebab8a1 Compare October 15, 2026 09:54
Comment thread include/nlohmann/json.hpp Outdated
static std::size_t capacity_of(const C& /*container*/) noexcept
{
return detail::unknown_size();
return std::is_same<C, array_t>::value || detail::is_ordered_map<C>::value ? detail::unknown_size() : 0;

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.

Should detail::unknown_size() be the default unless the container is known to not move? In other words, we check for containers that we know don't move and return "may move" for everything else, rather than checking for containers that we know move, and return "won't move" for everything else?

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.

Agreed, and it was worse than it looked: is_ordered_map is just has_capacity, so in that overload the check was always false, and every object type without capacity() counted as "never moves". Fixed in 80692a1. Only std::map, std::multimap, std::unordered_map and std::unordered_multimap count as stable (new detail::has_stable_elements), and only the real nlohmann::ordered_map has its capacity tracked (new detail::is_nlohmann_ordered_map). Everything else is treated as "may move". That also covers a sorted flat map that has capacity() but shifts members on a middle insert. The new test uses an object type that moves its members without changing its capacity; it fails without the change.

(Written by Claude Code on my behalf.)

Comment thread include/nlohmann/json.hpp Outdated

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.

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. set_parents_after_object_erase() now re-parents unless has_stable_elements<object_t>. After this PR, set_parent() had no callers left, so I removed it instead of changing it (80692a1).

(Written by Claude Code on my behalf.)

Comment thread include/nlohmann/json.hpp Outdated
// set_parent() also repairs the other members, which ordered_json
// relocates when adding a key makes its vector grow
set_parent(m_data.m_value.object->operator[](it.key()) = std::move(it.value()));
// operator[] repairs the other members if ordered_json relocates

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.

Should these all say "the container" rather than "ordered_json"?

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, reworded to "the object type" in both update() comments (80692a1).

(Written by Claude Code on my behalf.)

@nlohmann
nlohmann force-pushed the fix/diagnostics-ordered-json-quadratic branch 2 times, most recently from 80692a1 to a88d94f Compare October 15, 2026 17:43
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Address review: decide whether an insertion may have moved the other
members by naming the containers that are known not to, instead of the
ones that are known to.

- detail::has_stable_elements: std::map, std::multimap,
  std::unordered_map and std::unordered_multimap; object_capacity()
  reports 0 for them.
- detail::is_nlohmann_ordered_map: only the real nlohmann::ordered_map
  (which only appends) reports its capacity().
- Any other object type reports unknown_size(), so it gets a full
  set_parents() on every insertion. Before, an object type without
  capacity() was treated as never moving, and one with capacity() (a
  sorted flat map, say) was trusted to move only on reallocation.
- set_parents_after_object_erase() re-parents unless the object type is
  stable; the unused set_parent() is removed.
- Comments say "the object type" instead of "ordered_json".
- New test with an object type that moves its members without changing
  its capacity; it fails without this change.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the fix/diagnostics-ordered-json-quadratic branch from ed76b17 to 608808b Compare October 15, 2026 17:54
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 15, 2026
@nlohmann
nlohmann merged commit 4530d96 into develop Oct 15, 2026
3 of 165 checks passed
@nlohmann
nlohmann deleted the fix/diagnostics-ordered-json-quadratic branch October 15, 2026 18:01
nlohmann added a commit that referenced this pull request Oct 15, 2026
These PRs were merged while their CI runs were cancelled, so the warnings
went unnoticed. All of them fail the -Werror builds:

- json_pointer.hpp (#5836): size_t += int under GCC -Wsign-conversion
- binary_reader.hpp (#5839): char_int_type passed as char under GCC
  -Wconversion
- json.hpp (#5838): @throw without a description under clang
  -Wdocumentation
- string_escape.hpp (#5836): escape_into() was inserted between escape()
  and its documentation, so the @return was attached to a void function
- unit-diagnostics.cpp (#5832): clang -Wnrvo in a lambda without an
  explicit return type
- unit-to_json-rvalue.cpp (#5837): GCC -Wnoexcept (C++20) and clang
  -Wunneeded-member-function on the test type

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann added a commit that referenced this pull request Oct 16, 2026
- object_capacity() (#5832) overloaded a static with a non-static member
  function template, which MSVC 2015 rejects (C2686); make all three
  overloads non-static.
- The JSON pointer hash constants (#5836) truncate on 32-bit size_t
  (C4309 with /WX); mask them to size_t's range before the cast.
- unit-diagnostics: MSVC 2015 cannot use-declare the base's Container
  typedef (C2886); use alias declarations instead.

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 review needed It would be great if someone could review the proposed changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants