Resume patch operations from the common prefix in constant time per step

patch() and patch_inplace() keep the values along the previously resolved
pointer, so that the next operation resumes from the prefix the two paths
have in common. To find the resume point, each cached step was tested from the
deepest upwards with a memcmp over its whole prefix. If two paths shared a
prefix and the previous path continued for D more tokens, the search compared
D prefixes of that length: for two "test" operations whose paths share n
tokens and continue for n more, the time was quadratic in n (3.12.0 was
linear).

With more than 16 cached steps, the length of the common prefix of the two
paths is now computed once, and the deepest cached step that ends within it,
at a token boundary of the new path, is taken. Steps that are skipped are
dropped from the cache, so the search is amortized constant per token. With
fewer steps, which is the usual case, they are compared as before, as that
is cheaper for short paths. The operations, and which values stay valid after
them, are unchanged.

Measurements (Apple clang -O2, arm64), patch_inplace() of two "test"
operations whose paths share n tokens and continue for n more:

  n          25k    50k    100k    200k
  before    42 ms  182 ms 1194 ms 5433 ms
  after      0 ms    2 ms    4 ms    8 ms
  3.12.0     3 ms    5 ms   10 ms   19 ms

Patches of ordinary documents (diff() then patch(), and many short
operations): within 0.5 % of before in instructions retired (canada, citm_catalog,
twitter, mesh, update-center, many short synthetic operations).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This commit is contained in:
Niels Lohmann committed 2026-11-11 12:42:05 +01:00
1 parent 8d591bf720
commit 06b11f80fd
3 files changed
+495 -10

No files matched your search

+38 -5
View File
@@ -27266,6 +27266,32 @@ class json_pointer
}
};
/*!
@brief the number of the deepest step of the previous path that is a prefix of the new one
Comparing every one of many steps with the beginning of the path would take
quadratic time. Instead, the length of the common beginning of the two paths
is computed once: a step whose prefix is not longer is a prefix of the new
path, too. For few steps, which is the usual case, @ref resolve_plain
compares them, which is cheaper.
*/
template<typename Steps>
JSON_HEDLEY_NEVER_INLINE static std::size_t deepest_shared_step(const string_t& path, const std::size_t length, const string_t& previous, const Steps& steps) noexcept
{
const std::size_t same = common_prefix_length(path.data(), previous.data(), (std::min)(length, previous.size()));
std::size_t depth = steps.size();
for (; depth > 1; --depth)
{
// a step ends at a token boundary of the previous path
const std::size_t prefix = steps[depth - 1].first;
if (prefix <= same && (prefix == length || path[prefix] == '/'))
{
break;
}
}
return depth;
}
/*!
@brief resolve a plain JSON pointer without building its reference tokens
@@ -27294,13 +27320,20 @@ class json_pointer
if (cache.path != nullptr && !steps.empty() && steps[0].second == root)
{
const string_t& previous = *cache.path;
for (depth = steps.size(); depth > 1; --depth)
if (JSON_HEDLEY_UNLIKELY(steps.size() > 16))
{
// a step ends at a token boundary of the previous path
const std::size_t prefix = steps[depth - 1].first;
if (prefix <= length && (prefix == length || path[prefix] == '/') && std::memcmp(path.data(), previous.data(), prefix) == 0)
depth = deepest_shared_step(path, length, previous, steps);
}
else
{
for (depth = steps.size(); depth > 1; --depth)
{
break;
// a step ends at a token boundary of the previous path
const std::size_t prefix = steps[depth - 1].first;
if (prefix <= length && (prefix == length || path[prefix] == '/') && std::memcmp(path.data(), previous.data(), prefix) == 0)
{
break;
}
}
}
steps.resize(depth);