Skip to content

Do not search a container for the value the callback rejected - #5457

Merged
nlohmann merged 1 commit into
claude/benchmark-indented-parsefrom
claude/callback-parse-quadratic
Sep 11, 2026
Merged

nlohmann merged 1 commit into
claude/benchmark-indented-parsefrom
claude/callback-parse-quadratic

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 2, 2026 •

Copy link
Copy Markdown
Owner

json::parse(input, callback) is quadratic in the number of members of a single container when the callback rejects many of them.

Stack

develop
└─ claude/parser-performance-research-4hkdf8    (#5283)
   └─ claude/dump-function-performance-hcj2bl   (#5285)
      └─ claude/dump-adapter-devirt             (#5449)
         └─ claude/benchmark-indented-parse     (#5456)  ← base of this PR
            └─ claude/callback-parse-quadratic  (this PR)
               └─ claude/dom-handler-moves      (#5458)

When the callback rejects a value, the placeholder stored for it has to be removed from its parent again. remove_discarded_value() found it by scanning the parent from the beginning:

static void remove_discarded_value(BasicJsonType& parent)
{
    for (auto it = parent.begin(); it != parent.end(); ++it)
    {
        if (it->is_discarded()) { parent.erase(it); break; }
    }
}

so filtering a container costs one full scan per rejected member. Discarding half the members of one object:

members value rejected ms / n² × 10⁶
2 000 6.4 ms 1.59
8 000 96.9 ms 1.51
32 000 1 572 ms 1.54
128 000 25 339 ms 1.55

The flat ms / n² column is the quadratic. A 128k-member object takes 25 seconds to filter; a million-member one extrapolates to roughly 25 minutes.

The fix

A rejected value can only ever be the one most recently added to its parent: the last element of an array, or the placeholder key() stored under the current key in an object. Record that key alongside the existing key_keep_stack, and for a container record it again alongside ref_stack so end_object()/end_array() can find it in the parent. Removal becomes O(1) for an array and O(log n) for an object.

Finding no discarded value there means none was stored in the first place — the callback rejected it before it reached its parent — so there is nothing to remove and no scan is needed.

The key for a container is read before handle_value() may consume it, so it is also correct when the callback rejects the container at its object_start/array_start event and it never reaches its parent at all. That path was quadratic too:

members value rejected container rejected at start
16 000 392 ms → 3.7 ms 803 ms → 8.2 ms
64 000 6 238 ms → 14.6 ms 12 651 ms → 32.2 ms
128 000 25 339 ms → 30.6 ms

Both are now linear in n (time per member is flat).

Correctness

  • Full suite passes at C++11 and C++17.
  • 48 000 randomized documents parsed under 12 different filtering callbacks — covering duplicate keys, empty keys, rejected keys, and containers rejected at both their start and end events — produce byte-identical output before and after.
  • A 717-file corpus (including the 188 must-fail cases of nst_json_testsuite2) yields identical values and identical error messages on both the contiguous and the streaming adapter.
  • New test cases cover filtering many members of one container, via both the value and the container-end removal paths.

Public API impact

No breaking changes. json::parse(input, callback) keeps its signature and its behavior; every document parses to exactly the same value as before, and the same inputs still throw the same exceptions with the same messages.

The observable complexity of that documented API improves, from quadratic to linear in the number of members of a container. That is a behavior change only in the sense that inputs which previously took minutes now complete.

All changes are confined to detail::json_sax_dom_callback_parser, which is internal. Its private remove_discarded_value() gains a parameter and three private members are added; the class is not part of the documented API and cannot be usefully instantiated by callers.


This pull request was prepared by Claude Code.

@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from ec93161 to d05b377 Compare September 3, 2026 04:44
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch 2 times, most recently from 65d5e66 to 39263b4 Compare September 5, 2026 08:25
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from 39263b4 to 41300fe Compare September 9, 2026 11:17
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 9, 2026
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from 41300fe to d34ea13 Compare September 10, 2026 15:20
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch 2 times, most recently from 3b7ad7b to e979670 Compare September 10, 2026 15:28
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from 3b7ad7b to e979670 Compare September 10, 2026 15:28
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from e979670 to 7376a9c Compare September 10, 2026 15:49
@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 Sep 10, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Sep 10, 2026
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from 7376a9c to 797f61c Compare September 10, 2026 16:01
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch 2 times, most recently from daf765b to 0ed313c Compare September 10, 2026 17:01
When a parser callback rejects a value, the placeholder stored for it has to
be removed from its parent again. remove_discarded_value() found it by
scanning the parent from the beginning, so filtering a container cost one
scan per rejected member - quadratic in the number of members of a single
container.

A rejected value can only ever be the one most recently added to its parent:
the last element of an array, or the placeholder key() stored under the
current key in an object. Record that key alongside the existing
key_keep_stack, and for a container record it again alongside ref_stack so
end_object()/end_array() can find it in the parent. Removal is then O(1) for
an array and O(log n) for an object, and finding nothing there means nothing
was stored, so there is nothing to remove.

The key for a container is read before handle_value() may consume it, so it
is also correct when the callback rejects the container at its start event
and it never reaches its parent at all.

Discarding half the members of one object, before -> after:

     members     value rejected    container rejected at start
      16 000    392 ms -> 3.7 ms      803 ms ->  8.2 ms
      64 000   6238 ms -> 14.6 ms   12651 ms -> 32.2 ms
     128 000  25339 ms -> 30.6 ms

Results are unchanged: 48 000 randomized documents parsed under 12 different
filtering callbacks - covering duplicate keys, empty keys, rejected keys and
containers rejected at both their start and end events - produce byte
identical output before and after.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the claude/callback-parse-quadratic branch from 0ed313c to 14d9958 Compare September 11, 2026 06:19
@nlohmann
nlohmann merged commit 1dc1d09 into develop Sep 11, 2026
146 of 306 checks passed
@nlohmann
nlohmann deleted the claude/callback-parse-quadratic branch September 11, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants