From 6aefd8c662fa0cf5a7599365514210caa4aa06dd Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Wed, 21 Oct 2026 20:01:28 +0200 Subject: [PATCH] Copy short printable-ASCII strings without starting the scanner (#5878) dump() now checks strings of up to 32 bytes against a table of the bytes that can be written verbatim, four bytes per step, and copies them in one go if all of them can. That is cheaper than setting up the vector scanner, whose setup costs more than such a string takes to scan; SQLite's jsonAppendString() writes strings with the same kind of table loop. Most object keys and many values are this short (citm_catalog averages 8 bytes per string, jeopardy 14). GCC 16 and 13 stopped inlining put_indent() and put_literal() into the pretty-printing loops once dump_escaped() had grown, which made indented output up to 13% slower; both are now always inlined. The new test puts every ASCII character and a 2-byte character at every position of strings of 1 to 40 bytes and compares the output with the reference implementation of the existing UTF-8 tests. Signed-off-by: Niels Lohmann --- include/nlohmann/detail/output/serializer.hpp | 68 ++++++++++++++++++- single_include/nlohmann/json.hpp | 68 ++++++++++++++++++- tests/src/unit-serialization.cpp | 21 ++++++ 3 files changed, 153 insertions(+), 4 deletions(-) diff --git a/include/nlohmann/detail/output/serializer.hpp b/include/nlohmann/detail/output/serializer.hpp index 1dd5e41e3..7bb4edfc5 100644 --- a/include/nlohmann/detail/output/serializer.hpp +++ b/include/nlohmann/detail/output/serializer.hpp @@ -693,6 +693,33 @@ class serializer */ void dump_escaped(const string_t& s) { + // Short strings of printable ASCII - most object keys and many values - + // are checked byte by byte against a table and then copied in one go. + // For them, this is cheaper than starting the vector scanner below, + // whose setup costs more than such a string takes to scan. The same + // check serves both values of ensure_ascii. + if (s.size() <= 32) + { + const auto& copyable = ascii_copyable(); + const auto* const data = reinterpret_cast(s.data()); + const std::size_t n = s.size(); + std::size_t k = 0; + while (k + 4 <= n && copyable[data[k]] != 0 && copyable[data[k + 1]] != 0 + && copyable[data[k + 2]] != 0 && copyable[data[k + 3]] != 0) + { + k += 4; + } + while (k < n && copyable[data[k]] != 0) + { + ++k; + } + if (k == n) + { + put_string(s, 0, n); + return; + } + } + // dispatch once here rather than test the flag inside the loop: it does // not change while a string is written, and folding it lets each of the // two scanners be inlined into a loop of its own @@ -1073,8 +1100,12 @@ class serializer the indentation character once and flushing that same content repeatedly: flushing does not disturb what the buffer holds, so re-filling it between flushes would be redundant work. + + @note inlined on purpose, as is @ref put_literal: GCC otherwise stops + inlining both into the pretty-printing loops once @ref dump_escaped + has grown, which made indented output up to 13% slower */ - void put_indent(std::size_t indent) + JSON_HEDLEY_ALWAYS_INLINE void put_indent(std::size_t indent) { // closing braces at the outermost level ask for no indentation at all if (indent == 0) @@ -1124,7 +1155,7 @@ class serializer write-through path for oversized runs. */ template - void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_HEDLEY_ALWAYS_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 @@ -1233,6 +1264,39 @@ class serializer } } + /*! + @brief whether each byte can be written verbatim under either value of ensure_ascii + + Entry c is 1 for printable ASCII other than '"' and '\\' (see + is_ascii_copyable()) and 0 otherwise. A table lookup per byte is the + cheapest test for the short strings handled first in dump_escaped(). + */ + static const std::array& ascii_copyable() noexcept + { + static const std::array copyable = + { + { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x00 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x10 + 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x20 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x30 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x40 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, // 0x50 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x60 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, // 0x70 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x80 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x90 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xA0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xB0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xC0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xD0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xE0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 0xF0 + } + }; + return copyable; + } + /*! @brief the JSON form of each character below 0x60 diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 0ed2c6358..164ad22dd 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -30558,6 +30558,33 @@ class serializer */ void dump_escaped(const string_t& s) { + // Short strings of printable ASCII - most object keys and many values - + // are checked byte by byte against a table and then copied in one go. + // For them, this is cheaper than starting the vector scanner below, + // whose setup costs more than such a string takes to scan. The same + // check serves both values of ensure_ascii. + if (s.size() <= 32) + { + const auto& copyable = ascii_copyable(); + const auto* const data = reinterpret_cast(s.data()); + const std::size_t n = s.size(); + std::size_t k = 0; + while (k + 4 <= n && copyable[data[k]] != 0 && copyable[data[k + 1]] != 0 + && copyable[data[k + 2]] != 0 && copyable[data[k + 3]] != 0) + { + k += 4; + } + while (k < n && copyable[data[k]] != 0) + { + ++k; + } + if (k == n) + { + put_string(s, 0, n); + return; + } + } + // dispatch once here rather than test the flag inside the loop: it does // not change while a string is written, and folding it lets each of the // two scanners be inlined into a loop of its own @@ -30938,8 +30965,12 @@ class serializer the indentation character once and flushing that same content repeatedly: flushing does not disturb what the buffer holds, so re-filling it between flushes would be redundant work. + + @note inlined on purpose, as is @ref put_literal: GCC otherwise stops + inlining both into the pretty-printing loops once @ref dump_escaped + has grown, which made indented output up to 13% slower */ - void put_indent(std::size_t indent) + JSON_HEDLEY_ALWAYS_INLINE void put_indent(std::size_t indent) { // closing braces at the outermost level ask for no indentation at all if (indent == 0) @@ -30989,7 +31020,7 @@ class serializer write-through path for oversized runs. */ template - void put_literal(const char (&s)[N]) // NOLINT(cppcoreguidelines-avoid-c-arrays,hicpp-avoid-c-arrays,modernize-avoid-c-arrays) + JSON_HEDLEY_ALWAYS_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 @@ -31098,6 +31129,39 @@ class serializer } } + /*! + @brief whether each byte can be written verbatim under either value of ensure_ascii + + Entry c is 1 for printable ASCII other than '"' and '\\' (see + is_ascii_copyable()) and 0 otherwise. A table lookup per byte is the + cheapest test for the short strings handled first in dump_escaped(). + */ + static const std::array& ascii_copyable() noexcept + { + static const std::array copyable = + { + { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x00 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x10 + 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x20 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x30 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x40 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, // 0x50 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 0x60 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, // 0x70 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x80 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x90 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xA0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xB0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xC0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xD0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xE0 + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 0xF0 + } + }; + return copyable; + } + /*! @brief the JSON form of each character below 0x60 diff --git a/tests/src/unit-serialization.cpp b/tests/src/unit-serialization.cpp index b6608a49a..5722c75a0 100644 --- a/tests/src/unit-serialization.cpp +++ b/tests/src/unit-serialization.cpp @@ -1417,6 +1417,27 @@ void check_dump_against_reference(const std::string& s, const bool with_strict = } } // namespace +TEST_CASE("every ASCII character at every position of a short string is written like the reference") +{ + // dump_escaped() copies strings of up to 32 bytes of printable ASCII after + // a table lookup per byte, and hands everything else to the scanners; put + // each ASCII character and a 2-byte character at every position of + // strings of up to 40 bytes to cover both sides of that bound + for (std::size_t length = 1; length <= 40; ++length) + { + for (std::size_t pos = 0; pos < length; ++pos) + { + for (int c = 0; c < 0x80; ++c) + { + std::string s(length, 'a'); + s[pos] = static_cast(c); + check_dump_against_reference(s); + } + check_dump_against_reference(std::string(pos, 'a') + "\xC3\xA4" + std::string(length - pos - 1, 'a')); + } + } +} + TEST_CASE("invalid UTF-8 at every position is handled like the reference") { // ill-formed byte sequences; those that end in a truncated sequence are