From 964b433dc7692527262e659b3f2e43012f9207a3 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 13 Nov 2026 10:25:10 +0100 Subject: [PATCH] Force inlining of the serializer helpers only in optimized builds The seven serializer helpers marked JSON_HEDLEY_ALWAYS_INLINE are inlined at -O0 as well, and GCC and Clang give every inlined copy its own stack slots there. That made the recursive frame of dump_container() 2304 bytes with Clang 21 (2048 with GCC 16), against 480 bytes in 3.12.0, so dump() of a value nested beyond the default recursion depth limit needed 288 KiB of stack in an unoptimized build and crashed in a 128 KiB musl thread. Define JSON_INTERNAL_SERIALIZER_INLINE as JSON_HEDLEY_ALWAYS_INLINE when __OPTIMIZE__ (or _MSC_VER) is defined and as plain inline otherwise, and use it for these helpers. The frame drops to 320 bytes and dump() needs 48 KiB at the default limit. The -O2 object code is unchanged with Clang 21 and GCC 16. Update the stack tables in json_recursion_depth_limit.md and untrusted_input.md: copying and conversion are now the most demanding operations at -O0. Fixes #6116. Signed-off-by: Niels Lohmann --- .../api/macros/json_recursion_depth_limit.md | 29 ++++++++++--------- .../docs/features/parsing/untrusted_input.md | 8 ++--- include/nlohmann/detail/macro_scope.hpp | 10 +++++++ include/nlohmann/detail/macro_unscope.hpp | 1 + include/nlohmann/detail/output/serializer.hpp | 14 ++++----- single_include/nlohmann/json.hpp | 25 +++++++++++----- 6 files changed, 55 insertions(+), 32 deletions(-) diff --git a/docs/mkdocs/docs/api/macros/json_recursion_depth_limit.md b/docs/mkdocs/docs/api/macros/json_recursion_depth_limit.md index 0d7dc66e3..617dff12c 100644 --- a/docs/mkdocs/docs/api/macros/json_recursion_depth_limit.md +++ b/docs/mkdocs/docs/api/macros/json_recursion_depth_limit.md @@ -27,7 +27,7 @@ comparison of two [`json_view`](../basic_json_view/index.md) documents has a fix and does not depend on it either. The call stack that these operations need grows with the limit: roughly *a few kilobytes plus a cost per level times the -limit*, where the cost per level is up to about 2.3 KiB in an unoptimized and up to about 0.5 KiB in an optimized build +limit*, where the cost per level is up to about 1.8 KiB in an unoptimized and up to about 0.5 KiB in an optimized build (see [Stack needed](#stack-needed)). The default of `128` levels is tuned for threads with ordinary stacks of a few hundred kilobytes or more. Set a lower limit if your threads have small stacks (RTOS tasks, fibers, threads of the musl C @@ -67,7 +67,7 @@ When the macro is not defined, the library defines it to its default value. the levels that are descended into are counted, so the count never exceeds the limit, not even when user code (a copy constructor of a custom base class, say) copies a value while a copy is going on. A byte holds `255` at most, and the limit stays below that, so that the count cannot wrap around. (More levels would hardly be - useful: `254` levels need up to 576 KiB of stack in an unoptimized build already, twice as much as the + useful: `254` levels need up to 448 KiB of stack in an unoptimized build already, twice as much as the default.) ### Stack needed @@ -83,7 +83,7 @@ Unoptimized (`-O0`): | Operation | limit 16 | limit 32 | limit 64 | limit 128 | limit 254 | |---|---:|---:|---:|---:|---:| -| `dump()` and `dump(indent)`, `operator<<` | 48 / 32 | 80 / 64 | 144 / 128 | 288 / 256 | 576 / 512 | +| `dump()` and `dump(indent)`, `operator<<` | 16 / 16 | 16 / 16 | 32 / 32 | 48 / 48 | 96 / 96 | | copy construction and assignment | 32 / 16 | 48 / 32 | 112 / 80 | 224 / 144 | 432 / 304 | | `==` | 16 / 16 | 16 / 16 | 16 / 16 | 48 / 32 | 96 / 48 | | `<=>` (C++20) | 16 / 16 | 16 / 16 | 32 / 32 | 64 / 64 | 144 / 128 | @@ -123,23 +123,24 @@ The rule of thumb is > stack needed ≈ a few KiB + cost per level × limit -with a cost per level of up to 2.3 KiB (`dump` at `-O0` with Clang; 2.1 KiB with GCC), 1.7 KiB for copying and merging -(1.3 KiB with GCC), and 0.1 to 0.5 KiB for most other operations at `-O0`; and 0.5 KiB or less at `-O2`. The numbers -come from the difference between the limits `128` and `254` in the tables, so they are rough: other compilers, -other targets (the frames of x86-64 code are about as large), other standard libraries, `JSON_DIAGNOSTICS`, and -sanitizers change them. Measure with your own tool chain (see the example below) rather than relying on the tables. -The stack that your own code and the operating system need comes on top of these numbers. +with a cost per level of up to 1.8 KiB (conversion to another `basic_json` type at `-O0` with Clang; 1.3 KiB with +GCC), 1.7 KiB for copying and merging (1.3 KiB with GCC), 0.4 KiB for `dump`, and 0.1 to 0.5 KiB for most other +operations at `-O0`; and 0.5 KiB or less at `-O2`. The numbers come from the difference between the limits `128` and +`254` in the tables, so they are rough: other compilers, other targets (the frames of x86-64 code are about as large), +other standard libraries, `JSON_DIAGNOSTICS`, and sanitizers change them. Measure with your own tool chain (see the +example below) rather than relying on the tables. The stack that your own code and the operating system need comes on +top of these numbers. Which limit to choose, for the stack that you can spare for the library (the largest limit of those measured for which the slowest operation needs at most that much): | Stack for the library | optimized (`-O2`) | unoptimized (`-O0`) | |---|---:|---:| -| 32 KiB | 64 | 0 | -| 64 KiB | 128 | 16 | -| 128 KiB (the default stack of threads on musl) | 254 | 32 | -| 256 KiB | 254 | 64 | -| 512 KiB | 254 | 128 | +| 32 KiB | 64 | 16 | +| 64 KiB | 128 | 32 | +| 128 KiB (the default stack of threads on musl) | 254 | 64 | +| 256 KiB | 254 | 128 | +| 512 KiB | 254 | 254 | | 1 MiB or more | 254 | 254 | ### Speed diff --git a/docs/mkdocs/docs/features/parsing/untrusted_input.md b/docs/mkdocs/docs/features/parsing/untrusted_input.md index 0353bdfbd..fa40e3adc 100644 --- a/docs/mkdocs/docs/features/parsing/untrusted_input.md +++ b/docs/mkdocs/docs/features/parsing/untrusted_input.md @@ -64,13 +64,13 @@ stack for the nesting.) The call stack still needs room for the levels that are recursed into, and the default limit assumes a stack of ordinary size. The stack that an operation needs is roughly proportional to the limit; with the default of 128 levels, the most -demanding operation (`dump` in an unoptimized build) needed up to 288 KiB, and an optimized build needed 48 KiB or less, -in measurements on macOS with arm64. The most demanding operation needed this much stack at the limits `0`, `32`, -`128`, and `254`: +demanding operations (copying a value and converting it to another `basic_json` type, in an unoptimized build) needed +up to 224 KiB, and an optimized build needed 48 KiB or less, in measurements on macOS with arm64. The most demanding +operation needed this much stack at the limits `0`, `32`, `128`, and `254`: | Stack needed, in KiB | limit 0 | limit 32 | limit 128 (default) | limit 254 | |---|---:|---:|---:|---:| -| unoptimized build (`-O0`) | 16 or less | 80 | 288 | 576 | +| unoptimized build (`-O0`) | 16 or less | 64 | 224 | 448 | | optimized build (`-O2`) | 16 or less | 16 or less | 48 | 112 | A thread with a smaller stack (a task of a real-time operating system, a fiber, or a thread of the musl C library, whose diff --git a/include/nlohmann/detail/macro_scope.hpp b/include/nlohmann/detail/macro_scope.hpp index 76d191128..dd99b83c3 100644 --- a/include/nlohmann/detail/macro_scope.hpp +++ b/include/nlohmann/detail/macro_scope.hpp @@ -230,6 +230,16 @@ // detail/recursion_depth_limit.hpp. Like JSON_NO_THREAD_LOCAL it is neither part // of the ABI tag nor undefined at the end of json.hpp. +// forced inlining of the serializer's helpers only in optimized builds: at -O0, +// GCC and Clang give every inlined copy its own stack slots, which made the +// recursive frame of serializer::dump_container() about 2.3 KB (MSVC does not +// expand __forceinline at /Ob0, so it keeps it) +#if defined(__OPTIMIZE__) || defined(_MSC_VER) + #define JSON_INTERNAL_SERIALIZER_INLINE JSON_HEDLEY_ALWAYS_INLINE +#else + #define JSON_INTERNAL_SERIALIZER_INLINE inline +#endif + // allow disabling exceptions #if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) && !defined(JSON_NOEXCEPTION) #define JSON_THROW(exception) throw exception diff --git a/include/nlohmann/detail/macro_unscope.hpp b/include/nlohmann/detail/macro_unscope.hpp index b61db5dc6..6c8e1175f 100644 --- a/include/nlohmann/detail/macro_unscope.hpp +++ b/include/nlohmann/detail/macro_unscope.hpp @@ -11,6 +11,7 @@ // clean up #undef JSON_ASSERT #undef JSON_INTERNAL_CATCH +#undef JSON_INTERNAL_SERIALIZER_INLINE #undef JSON_THROW #undef JSON_PRIVATE_UNLESS_TESTED #undef NLOHMANN_BASIC_JSON_TPL_DECLARATION diff --git a/include/nlohmann/detail/output/serializer.hpp b/include/nlohmann/detail/output/serializer.hpp index a2ba87205..eeba0a9de 100644 --- a/include/nlohmann/detail/output/serializer.hpp +++ b/include/nlohmann/detail/output/serializer.hpp @@ -265,7 +265,7 @@ class serializer for a container; writing them from here spares each the entry and exit of that function. */ - JSON_HEDLEY_ALWAYS_INLINE void dump_element(const BasicJsonType& val, + JSON_INTERNAL_SERIALIZER_INLINE void dump_element(const BasicJsonType& val, const std::size_t current_indent, const std::size_t depth) { @@ -971,7 +971,7 @@ class serializer } /// write out the bytes pending in @ref string_buffer and reset @a bytes - JSON_HEDLEY_ALWAYS_INLINE void emit_pending(std::size_t& bytes) + JSON_INTERNAL_SERIALIZER_INLINE void emit_pending(std::size_t& bytes) { if (bytes != 0) { @@ -983,7 +983,7 @@ class serializer /// write buffer and reset index if fewer than 13 bytes are left, as this /// is the maximal number of bytes to be written ("\uxxxx\uxxxx\0") for one /// code point - JSON_HEDLEY_ALWAYS_INLINE void reserve_string_buffer(std::size_t& bytes) + JSON_INTERNAL_SERIALIZER_INLINE void reserve_string_buffer(std::size_t& bytes) { if (string_buffer.size() - bytes < 13) { @@ -1344,7 +1344,7 @@ class serializer inlining both into the pretty-printing loops once @ref dump_escaped has grown, which made indented output up to 13% slower */ - JSON_HEDLEY_ALWAYS_INLINE void put_indent(std::size_t indent) + JSON_INTERNAL_SERIALIZER_INLINE void put_indent(std::size_t indent) { // closing braces at the outermost level ask for no indentation at all if (indent == 0) @@ -1398,7 +1398,7 @@ class serializer its room and four stores of the indentation, see @ref put_short_literal_indent. */ template - JSON_HEDLEY_ALWAYS_INLINE void put_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE void put_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { if (JSON_HEDLEY_LIKELY(put_short_literal_indent(s, indent))) { @@ -1418,7 +1418,7 @@ class serializer @a indent is wider or the write buffer lacks the room. */ template - JSON_HEDLEY_ALWAYS_INLINE bool put_short_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE bool put_short_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { constexpr std::size_t length = N - 1; if (indent > 32 || write_buffer_pos + length + 32 > write_buffer.size()) @@ -1445,7 +1445,7 @@ class serializer write-through path for oversized runs. */ template - JSON_HEDLEY_ALWAYS_INLINE void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { static_assert(N >= 2, "put_literal expects a non-empty string literal"); // the array bound counts the terminating NUL, which is not written diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 102eb94ad..36d6974e5 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -2603,6 +2603,16 @@ JSON_HEDLEY_DIAGNOSTIC_POP // detail/recursion_depth_limit.hpp. Like JSON_NO_THREAD_LOCAL it is neither part // of the ABI tag nor undefined at the end of json.hpp. +// forced inlining of the serializer's helpers only in optimized builds: at -O0, +// GCC and Clang give every inlined copy its own stack slots, which made the +// recursive frame of serializer::dump_container() about 2.3 KB (MSVC does not +// expand __forceinline at /Ob0, so it keeps it) +#if defined(__OPTIMIZE__) || defined(_MSC_VER) + #define JSON_INTERNAL_SERIALIZER_INLINE JSON_HEDLEY_ALWAYS_INLINE +#else + #define JSON_INTERNAL_SERIALIZER_INLINE inline +#endif + // allow disabling exceptions #if (defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND)) && !defined(JSON_NOEXCEPTION) #define JSON_THROW(exception) throw exception @@ -36985,7 +36995,7 @@ class serializer for a container; writing them from here spares each the entry and exit of that function. */ - JSON_HEDLEY_ALWAYS_INLINE void dump_element(const BasicJsonType& val, + JSON_INTERNAL_SERIALIZER_INLINE void dump_element(const BasicJsonType& val, const std::size_t current_indent, const std::size_t depth) { @@ -37691,7 +37701,7 @@ class serializer } /// write out the bytes pending in @ref string_buffer and reset @a bytes - JSON_HEDLEY_ALWAYS_INLINE void emit_pending(std::size_t& bytes) + JSON_INTERNAL_SERIALIZER_INLINE void emit_pending(std::size_t& bytes) { if (bytes != 0) { @@ -37703,7 +37713,7 @@ class serializer /// write buffer and reset index if fewer than 13 bytes are left, as this /// is the maximal number of bytes to be written ("\uxxxx\uxxxx\0") for one /// code point - JSON_HEDLEY_ALWAYS_INLINE void reserve_string_buffer(std::size_t& bytes) + JSON_INTERNAL_SERIALIZER_INLINE void reserve_string_buffer(std::size_t& bytes) { if (string_buffer.size() - bytes < 13) { @@ -38064,7 +38074,7 @@ class serializer inlining both into the pretty-printing loops once @ref dump_escaped has grown, which made indented output up to 13% slower */ - JSON_HEDLEY_ALWAYS_INLINE void put_indent(std::size_t indent) + JSON_INTERNAL_SERIALIZER_INLINE void put_indent(std::size_t indent) { // closing braces at the outermost level ask for no indentation at all if (indent == 0) @@ -38118,7 +38128,7 @@ class serializer its room and four stores of the indentation, see @ref put_short_literal_indent. */ template - JSON_HEDLEY_ALWAYS_INLINE void put_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE void put_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { if (JSON_HEDLEY_LIKELY(put_short_literal_indent(s, indent))) { @@ -38138,7 +38148,7 @@ class serializer @a indent is wider or the write buffer lacks the room. */ template - JSON_HEDLEY_ALWAYS_INLINE bool put_short_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE bool put_short_literal_indent(const char (&s)[N], const std::size_t indent) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { constexpr std::size_t length = N - 1; if (indent > 32 || write_buffer_pos + length + 32 > write_buffer.size()) @@ -38165,7 +38175,7 @@ class serializer write-through path for oversized runs. */ template - JSON_HEDLEY_ALWAYS_INLINE void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_INTERNAL_SERIALIZER_INLINE void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) { static_assert(N >= 2, "put_literal expects a non-empty string literal"); // the array bound counts the terminating NUL, which is not written @@ -47885,6 +47895,7 @@ struct formatter // NOLINT(cert-dcl58-c // clean up #undef JSON_ASSERT #undef JSON_INTERNAL_CATCH +#undef JSON_INTERNAL_SERIALIZER_INLINE #undef JSON_THROW #undef JSON_PRIVATE_UNLESS_TESTED #undef NLOHMANN_BASIC_JSON_TPL_DECLARATION