Compare commits

..
Author SHA1 Message Date
Niels Lohmann 4006a406a7 Merge develop; fix clang-tidy findings in json_view
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-25 19:03:32 +01:00
Niels Lohmann 2bc2f7d09d json_view: fewer allocations when parsing small documents
- Size the buffer of decoded strings on its first growth from the input (a
  quarter of it plus 16 bytes, at most 4 KiB) instead of 272 bytes, so that a
  document with escapes does not grow it twice, with a zero-filled copy each
  time.
- Allocate the node index of documents with an estimate of up to 1024 nodes
  (was 256) together with the header, which saves one allocation. When the
  estimate is too low (dense numbers), that block stays unused: at most 16 KiB
  instead of 4 KiB. shrink_to_fit() cannot release it separately; the
  documentation says so.

Fresh parse of a 2.5 KB minified tweet: 993 to 958 ns (-3.5 %), traverse and
select -3 %, with no row consistently slower (Apple M1 Max, 30 rounds, three
runs). Dense numeric documents parse about 9 % faster and keep up to 16 KiB
more memory.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-25 11:30:30 +01:00
11 changed files with 91 additions and 32 deletions

No files matched your search

-1
View File
@@ -108,7 +108,6 @@ is thrown. In any case, the original value is not changed: the patch is applied
- Added in version 2.0.0. - Added in version 2.0.0.
- Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's - Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's
target location has a non-object/non-array parent in version 3.13.0. target location has a non-object/non-array parent in version 3.13.0.
This includes a `null` parent, which was previously silently converted to an object, in version 3.13.0.
- Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target - Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target
location has a non-object/non-array parent in version 3.13.0. location has a non-object/non-array parent in version 3.13.0.
- Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper - Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper
@@ -106,7 +106,6 @@ function throws an exception.
- Added in version 3.11.0. - Added in version 3.11.0.
- Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's - Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's
target location has a non-object/non-array parent in version 3.13.0. target location has a non-object/non-array parent in version 3.13.0.
This includes a `null` parent, which was previously silently converted to an object, in version 3.13.0.
- Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target - Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target
location has a non-object/non-array parent in version 3.13.0. location has a non-object/non-array parent in version 3.13.0.
- Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper - Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper
@@ -23,6 +23,10 @@ Linear in [`node_count()`](node_count.md) plus the length of the decoded strings
## Notes ## Notes
The node index of a small document (an estimate of up to 1024 nodes, which is up to about 4 KiB of minified text) is
allocated together with the document's header, so that parsing needs one allocation. That part cannot be released
separately: `shrink_to_fit()` only trims a node index that has its own allocation, and the decoded strings.
!!! warning "Invalidates views" !!! warning "Invalidates views"
Unlike moving the document, `shrink_to_fit()` **invalidates every view taken from this document before the Unlike moving the document, `shrink_to_fit()` **invalidates every view taken from this document before the
-2
View File
@@ -1100,8 +1100,6 @@ A JSON Patch `add` operation cannot be applied because the target location's par
This exception was added in version 3.13.0. Before that, this situation hit an internal assertion (aborting the program in debug builds) or was silently ignored when assertions were disabled. This exception was added in version 3.13.0. Before that, this situation hit an internal assertion (aborting the program in debug builds) or was silently ignored when assertions were disabled.
A `null` parent is rejected as well. Before version 3.13.0, an `add` into a `null` parent silently converted it to an object (e.g., `add` at `/foo/bar` turned `{"foo":null}` into `{"foo":{"bar":1}}`).
### json.exception.out_of_range.412 ### json.exception.out_of_range.412
BSON stores the length of documents, arrays, strings, and binary values in a signed 32-bit integer, and MessagePack BSON stores the length of documents, arrays, strings, and binary values in a signed 32-bit integer, and MessagePack
+9 -1
View File
@@ -543,7 +543,15 @@ run_found:
NLOHMANN_VIEW_NOINLINE void arena_grow(std::size_t n) NLOHMANN_VIEW_NOINLINE void arena_grow(std::size_t n)
{ {
const std::size_t used = arena_used(); const std::size_t used = arena_used();
m_doc.arena.resize((std::max)(m_doc.arena.size() * 2, used + n + 256)); std::size_t wanted = used + n + 256;
if (m_doc.arena.empty())
{
// the first growth: a decoded string is never longer than its source,
// so a fraction of the input (capped, as the buffer is zero-filled)
// covers the escapes of a typical document without regrowing it
wanted = (std::max)(wanted, (std::min)((static_cast<std::size_t>(m_end - m_begin) / 4) + 16, std::size_t{4096}));
}
m_doc.arena.resize((std::max)(m_doc.arena.size() * 2, wanted));
m_arena_write = m_doc.arena.data() + used; m_arena_write = m_doc.arena.data() + used;
m_arena_end = m_doc.arena.data() + m_doc.arena.size(); m_arena_end = m_doc.arena.data() + m_doc.arena.size();
} }
@@ -82,14 +82,17 @@ struct document_data
}; };
std::unique_ptr<edit_state> edits; ///< created by the first edit std::unique_ptr<edit_state> edits; ///< created by the first edit
/// the most nodes that are allocated together with the header (16 KiB)
static constexpr std::size_t max_inline_nodes = 1024;
/// One allocation for the header and room for `count` nodes, so that a /// One allocation for the header and room for `count` nodes, so that a
/// small document needs one allocation instead of two. Large documents get /// small document needs one allocation instead of two. Large documents get
/// a separate node array instead (so it can be trimmed). When a document /// a separate node array instead (so it can be trimmed). When a document
/// outgrows the inline array, that array stays unused: at most 256 nodes /// outgrows the inline array, that array stays unused: at most 1024 nodes
/// (4 KiB), and only for documents whose estimate was too small. /// (16 KiB), and only for documents whose estimate was too small.
static document_data* create(std::size_t count) static document_data* create(std::size_t count)
{ {
count = count <= 256 ? count : 0; count = count <= max_inline_nodes ? count : 0;
void* mem = ::operator new (sizeof(document_data) + (count * sizeof(node))); void* mem = ::operator new (sizeof(document_data) + (count * sizeof(node)));
auto* d = new (mem) document_data(); // NOLINT(cppcoreguidelines-owning-memory): owned by the returned pointer, freed by deleter auto* d = new (mem) document_data(); // NOLINT(cppcoreguidelines-owning-memory): owned by the returned pointer, freed by deleter
// (aligned: sizeof is a multiple of the alignment; through void*, as GCC's -Wcast-align wants) // (aligned: sizeof is a multiple of the alignment; through void*, as GCC's -Wcast-align wants)
+1 -1
View File
@@ -7003,6 +7003,7 @@ public:
switch (parent.m_data.m_type) switch (parent.m_data.m_type)
{ {
case value_t::null:
case value_t::object: case value_t::object:
{ {
// use operator[] to add value // use operator[] to add value
@@ -7034,7 +7035,6 @@ public:
// the parent of an "add" target must be an object or array // the parent of an "add" target must be an object or array
// (see #4292) // (see #4292)
case value_t::null:
case value_t::string: case value_t::string:
case value_t::boolean: case value_t::boolean:
case value_t::number_integer: case value_t::number_integer:
+1 -1
View File
@@ -42793,6 +42793,7 @@ public:
switch (parent.m_data.m_type) switch (parent.m_data.m_type)
{ {
case value_t::null:
case value_t::object: case value_t::object:
{ {
// use operator[] to add value // use operator[] to add value
@@ -42824,7 +42825,6 @@ public:
// the parent of an "add" target must be an object or array // the parent of an "add" target must be an object or array
// (see #4292) // (see #4292)
case value_t::null:
case value_t::string: case value_t::string:
case value_t::boolean: case value_t::boolean:
case value_t::number_integer: case value_t::number_integer:
+15 -4
View File
@@ -397,14 +397,17 @@ struct document_data
}; };
std::unique_ptr<edit_state> edits; ///< created by the first edit std::unique_ptr<edit_state> edits; ///< created by the first edit
/// the most nodes that are allocated together with the header (16 KiB)
static constexpr std::size_t max_inline_nodes = 1024;
/// One allocation for the header and room for `count` nodes, so that a /// One allocation for the header and room for `count` nodes, so that a
/// small document needs one allocation instead of two. Large documents get /// small document needs one allocation instead of two. Large documents get
/// a separate node array instead (so it can be trimmed). When a document /// a separate node array instead (so it can be trimmed). When a document
/// outgrows the inline array, that array stays unused: at most 256 nodes /// outgrows the inline array, that array stays unused: at most 1024 nodes
/// (4 KiB), and only for documents whose estimate was too small. /// (16 KiB), and only for documents whose estimate was too small.
static document_data* create(std::size_t count) static document_data* create(std::size_t count)
{ {
count = count <= 256 ? count : 0; count = count <= max_inline_nodes ? count : 0;
void* mem = ::operator new (sizeof(document_data) + (count * sizeof(node))); void* mem = ::operator new (sizeof(document_data) + (count * sizeof(node)));
auto* d = new (mem) document_data(); // NOLINT(cppcoreguidelines-owning-memory): owned by the returned pointer, freed by deleter auto* d = new (mem) document_data(); // NOLINT(cppcoreguidelines-owning-memory): owned by the returned pointer, freed by deleter
// (aligned: sizeof is a multiple of the alignment; through void*, as GCC's -Wcast-align wants) // (aligned: sizeof is a multiple of the alignment; through void*, as GCC's -Wcast-align wants)
@@ -1734,7 +1737,15 @@ run_found:
NLOHMANN_VIEW_NOINLINE void arena_grow(std::size_t n) NLOHMANN_VIEW_NOINLINE void arena_grow(std::size_t n)
{ {
const std::size_t used = arena_used(); const std::size_t used = arena_used();
m_doc.arena.resize((std::max)(m_doc.arena.size() * 2, used + n + 256)); std::size_t wanted = used + n + 256;
if (m_doc.arena.empty())
{
// the first growth: a decoded string is never longer than its source,
// so a fraction of the input (capped, as the buffer is zero-filled)
// covers the escapes of a typical document without regrowing it
wanted = (std::max)(wanted, (std::min)((static_cast<std::size_t>(m_end - m_begin) / 4) + 16, std::size_t{4096}));
}
m_doc.arena.resize((std::max)(m_doc.arena.size() * 2, wanted));
m_arena_write = m_doc.arena.data() + used; m_arena_write = m_doc.arena.data() + used;
m_arena_end = m_doc.arena.data() + m_doc.arena.size(); m_arena_end = m_doc.arena.data() + m_doc.arena.size();
} }
-18
View File
@@ -1515,24 +1515,6 @@ TEST_CASE("JSON patch - add to a primitive parent (regression #4292)")
#endif #endif
} }
SECTION("null parent")
{
json const doc = {{"foo", nullptr}};
json const patch = {{{"op", "add"}, {"path", "/foo/bar"}, {"value", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] (/foo) cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&);
#endif
}
SECTION("null document")
{
json const doc = nullptr;
json const patch = {{{"op", "add"}, {"path", "/-"}, {"value", 1}}};
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&);
}
SECTION("number parent") SECTION("number parent")
{ {
json const doc = {{"foo", 1}}; json const doc = {{"foo", 1}};
+55
View File
@@ -666,6 +666,61 @@ TEST_CASE("json_view")
CHECK(small.root().materialize() == json::parse("[1,[2,3],{\"a\":\"b\\n\"}]")); CHECK(small.root().materialize() == json::parse("[1,[2,3],{\"a\":\"b\\n\"}]"));
} }
SECTION("block sizes")
{
// documents whose node estimate lies around the size that is allocated
// with the header, and whose decoded strings need the first growth of
// the buffer, in all sizes around the points where the policies change
for (const int count :
{
1, 2, 10, 50, 100, 255, 256, 257, 500, 1000, 1023, 1024, 1025, 2000, 5000
})
{
// strings with escapes (decoded text, minified)
std::string escaped = "[";
for (int i = 0; i < count; ++i)
{
escaped += (i != 0 ? ",\"a\\n" : "\"a\\n") + std::to_string(i) + "\\u00e4\"";
}
escaped += ']';
json_document e = json_document::parse(escaped);
CHECK(e.root().materialize() == json::parse(escaped));
e.shrink_to_fit();
CHECK(e.root().materialize() == json::parse(escaped));
// dense numbers (more nodes per byte than the estimate expects)
std::string dense = "[";
for (int i = 0; i < count; ++i)
{
dense += (i != 0 ? ",1" : "1");
}
dense += ']';
json_document n = json_document::parse(dense);
CHECK(n.node_count() == static_cast<std::size_t>(count) + 1);
CHECK(n.root().materialize() == json::parse(dense));
n.shrink_to_fit();
CHECK(n.root().size() == static_cast<std::size_t>(count));
// the same documents, pretty-printed
const std::string pretty = json::parse(escaped).dump(2);
json_document p = json_document::parse(pretty);
CHECK(p.root().materialize() == json::parse(escaped));
}
// a large document with a single escape does not need a buffer of the
// size of its input
std::string large = R"(["a\n")";
for (int i = 0; i < 100000; ++i)
{
large += ",1";
}
large += ']';
const json_document l = json_document::parse(large);
CHECK(l.root().size() == 100001);
CHECK(l.root()[0].get_string() == "a\n");
CHECK(l.root()[100000].get<int>() == 1);
}
SECTION("source offsets") SECTION("source offsets")
{ {
const std::string text = R"( {"key": "value", "escaped": "a\nb", "n": 42})"; const std::string text = R"( {"key": "value", "escaped": "a\nb", "n": 42})";