Skip to content

Unflatten in time and memory linear in the pointer depth - #5793

Merged
nlohmann merged 3 commits into
developfrom
fix-unflatten-quadratic
Oct 9, 2026
Merged

nlohmann merged 3 commits into
developfrom
fix-unflatten-quadratic

Conversation

@nlohmann

@nlohmann nlohmann commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #5792. Review and merge that one first; this PR's diff is just the last commit.

Problem

Since #5443 (c72f37a, not in any release yet), unflatten() takes time and memory quadratic in the depth of a pointer. To decide between arrays and objects independently of iteration order, it collects the prefixes that have a reference token 0 below them in a std::set<std::vector<string_t>>:

  • each 0 token inserts a copy of the whole prefix so far, so an array chain of depth d stores about d²/2 strings
  • get_and_create() pushes onto a prefix vector and looks it up in the set at every step, comparing whole vectors

On develop, unflattening {"/0/0/…/0": 1}:

depth time peak RSS
1,000 125 ms 16 MB
10,000 17.9 s 1.28 GB
100,000 killed after 86 s at 6 GB

#5393 had confirmed that unflatten() handled a 500,000-level pointer before #5443.

Change

The prefixes are now numbered nodes of a tree (prefix_tree): children[id] maps a token to the id of the extended prefix, and is_array[id] records whether a 0 token appears directly below it. The first pass of unflatten() builds the tree, storing each prefix once. get_and_create() then follows it token by token instead of building and comparing prefix vectors. The rule from #5443 is unchanged: a value becomes an array exactly when some key has a 0 directly below it, regardless of iteration order.

Benchmark

Deep pointer, unflatten() only, -O2, Apple clang 21, arm64:

depth kind before after
10,000 arrays 17.9 s / 1.28 GB 1.9 ms / 3.9 MB
100,000 arrays did not finish 12.9 ms / 24.7 MB
100,000 objects 6.8 ms / 21.6 MB 10.8 ms / 31.2 MB

Object chains weren't quadratic before (no 0 tokens), so the tree adds a small constant cost there.

Ordinary documents, best-of-7, median of 3 alternating runs (dump() output identical in every case):

input type new/old
wide, 1.3 MB, 82,503 entries json 1.01
wide ordered_json 1.07
nested, depth 7, 100,374 leaves json 0.65
nested ordered_json 0.64

The wide cases are within the noise of this benchmark (runs varied by up to ±25%). Wide ordered_json may be slower by about 5%.

Tests

  • The 100,000-level test from Flatten deeply nested values without recursing per nesting level #5792 now round-trips through unflatten() at full depth; it was limited to 500 levels before.
  • New: unflattening a single 100,000-level array pointer and a 100,000-level object pointer, checked by walking the result.
  • New: order independence with ordered_json, where a 0 key comes after its sibling ({"/a/1": 2, "/a/0": 1} → {"a": [1, 2]}) and a prefix without a 0 key ({"/b/1": 2} → {"b": {"1": 2}}). The existing tests used json, whose sorted keys never put 0 second.

Locally: test-json_pointer (C++11, C++20), test-json_patch, test-alt-string, test-custom-array-type, test-custom-object-type, test-diagnostics and test-ordered_json2 pass, and the build shows no warnings.

Public API

No breaking changes. Only private members of json_pointer change (array_parents_t → prefix_tree, and the parameter of get_and_create), and the output of unflatten() is identical.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5794 October 9, 2026 07:50
@nlohmann
nlohmann force-pushed the fix-unflatten-quadratic branch from 04dff2f to 4ffcbb5 Compare October 9, 2026 15:06
Base automatically changed from fix-5393-flatten-recursion to develop October 9, 2026 15:06
#5443 made unflatten() decide between arrays and objects independently
of the iteration order by collecting the pointer prefixes that have a
reference token 0 below them in a std::set<std::vector<string_t>>.
Every such prefix was stored as a copy of all its reference tokens, and
get_and_create() compared whole prefix vectors at every step, so
unflattening a pointer of depth d took time and memory quadratic in d:
a 10,000-level array pointer took 18 s and 1.3 GB, a 100,000-level one
did not finish.

The prefixes are now numbered nodes of a tree, so each is stored once
and get_and_create() follows the tree token by token. The result is
unchanged, including its independence of the iteration order.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the fix-unflatten-quadratic branch from 4ffcbb5 to b98dea4 Compare October 9, 2026 15:06
Comment thread include/nlohmann/detail/json_pointer.hpp Outdated
Comment thread include/nlohmann/detail/json_pointer.hpp Outdated
The constructor now creates the root node, add_child() inserts a
reference token below a prefix and returns the child's number, and
find_child() looks one up for get_and_create().

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 9, 2026
@nlohmann
nlohmann merged commit 2913e96 into develop Oct 9, 2026
4 of 168 checks passed
@nlohmann
nlohmann deleted the fix-unflatten-quadratic branch October 9, 2026 15:57
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.

2 participants