Compare commits

...
Author SHA1 Message Date
Niels Lohmann a800ded53d Simplify the serializer as suggested in review
- Test JSON_LITTLE_ENDIAN_TARGET (#5876) instead of repeating its condition
  as JSON_DUMP_SHORT_STRINGS.
- Let dump() write a top-level scalar through dump_element(), so that
  dump_internal() becomes dump_container() and handles objects and arrays
  only; pass 0 as indentation on the compact paths.
- Rename put_quoted() to put_object_key(), its only use.
- Share the fast path of put_indent() and put_literal_indent() in
  put_short_literal_indent(), and use put_literal_indent() for binary values
  and the closing brackets of dump_iteratively().
- Assert that write_u_escapes() sees sequences of 2 to 4 bytes only.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 19:41:03 +02:00
Niels Lohmann ff8ba3cb74 Keep the serializer's buffers out of -Weffc++ and cpplint warnings
GCC's -Weffc++ asks for the three buffers in the member initialization
list, which would zero them again. They are now of a type with an empty
user-provided constructor (as the json_view builder's frame stack), so
they are listed there and stay uninitialized.

The 8-to-31-byte branch of the short-string writer no longer spans an
#if with a dangling else, which cpplint read as an else body without
braces.

dump() instructions (Apple clang, twitter/citm/canada, compact and
indented) are unchanged within noise (-0.3 % to +0.6 %).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 10:51:40 +02:00
Niels Lohmann 0c3e83325a Test strings of 8 to 15 characters as one vector
The words of a string of 8 to 15 printable ASCII characters were each tested
with the word-at-a-time arithmetic. With SSE2 or NEON, the two overlapping
words are joined into one vector, which takes fewer operations to test; the
stores are unchanged. Targets without either keep the word arithmetic.

Apple M1, Apple clang, retired instructions per dump(), on top of the
previous changes:
  jeopardy 451M -> 430M (-5 %)   citm_catalog 5.60M -> 5.37M (-4 %)
  twitter 3.31M -> 3.25M (-2 %)  keys and values of 1..40 characters -1 %
Linux x86-64 (callgrind), gcc 13 / clang 18: jeopardy -15 % / -7 %,
citm_catalog -6 % / -5 %, twitter -6 % / -4 %.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:31 +02:00
Niels Lohmann ebcd4ae4ab Copy the short runs between escapes as a block of 16 bytes
The characters between two escapes (a quote, a backslash, a control
character) are copied to the write buffer by memcpy with a length known only
at run time. For a run of up to 16 characters that has 16 or more bytes in
the string behind its start and in the buffer behind its end, a copy of a
fixed block of 16 bytes does the same without the call; the surplus bytes in
the buffer are overwritten by what follows.

Apple M1, Apple clang, retired instructions per dump(), on top of the
previous changes:
  JSON in JSON 8.32M -> 7.52M (-10 %)   paths 8.17M -> 7.49M (-8 %)
  log lines 7.49M -> 7.14M (-5 %)       cjk, emoji text (no escapes): +-1 %
Linux x86-64 (callgrind), gcc 13 / clang 18:
  JSON in JSON -11 % / -10 %, paths -7 % / -6 %, log lines -5 % / -4 %.
The branch is marked unlikely: text without escapes (CJK, accented Latin)
has few short runs, and without the hint it paid up to 5 % for the check.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:28 +02:00
Niels Lohmann 47462527b2 Write a line break and the indentation of the next line together
A pretty-printed array or object wrote ",\n", checked the room for the
indentation, and stored it, for every element. The line break and the
usual indentation (up to 32 characters, with room in the write buffer) are
now written with one check and one run of stores, also after the opening
bracket and in front of the closing one. Other indentations take the
unchanged path.

Apple M1, Apple clang, retired instructions per dump(4), on top of the
previous changes:
  citm_catalog 7.28M -> 7.06M (-3 %)   canada 44.4M -> 43.3M (-3 %)
  twitter 3.91M -> 3.78M (-3 %)        jeopardy -1 %
Linux x86-64 (callgrind), gcc 13 / clang 18, dump(4) / dump(1, '\t'):
  citm_catalog dump(4) -3 % / -4 %, tab -4 % / -5 %, with the function
  inlined (without that GCC wrote the combined step out of line and was
  5 % slower than before the change).
Compact output is unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:26 +02:00
Niels Lohmann ae30ba55c3 Write strings and keys of up to 32 characters in one go with vectors
The previous change handles plain strings of up to 16 characters with
two overlapping 8-byte words. With SSE2 or NEON, two overlapping 16-byte
vectors do the same for 16 to 32 characters: one test for a quote,
backslash, control character, DEL or non-ASCII byte over both, and two
stores. Targets without either keep the limit of 16.

Apple M1, Apple clang, retired instructions per dump(), on top of the
previous changes (it replaces the table of #5878 for these strings):
  keys and values of 1..40 characters 7.07M -> 4.94M (-30 %)
  twitter 4.15M -> 3.30M (-21 %)   paths 9.09M -> 8.08M (-11 %)
  citm_catalog 5.76M -> 5.53M (-4 %)   jeopardy 461M -> 451M (-2 %)
Linux x86-64 (callgrind), gcc 13 / clang 18: twitter -11 % / -9 %,
keys and values of 1..40 characters -19 % / -17 %, paths -7 % / -7 %.
Documents without strings in that range are unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:23 +02:00
Niels Lohmann 731363a325 Write the usual indentation with four stores instead of memset
Every line of a pretty-printed document starts with its indentation, which
was written by calling memset with a length known only at run time.

An indentation of up to 32 characters, with room in the write buffer,
stores four words of the indentation character and claims the part it
needs. Wider indentations and a nearly full buffer take the unchanged
path.

Apple M1, Apple clang, retired instructions per dump(4), on top of the
previous changes:
  canada  50.7M -> 44.4M (-13 %)   citm_catalog 8.85M -> 7.48M (-16 %)
  twitter 5.13M -> 4.71M (-8 %)    jeopardy 564M -> 518M (-8 %)
  a 139-byte object 3277 -> 2924 (-11 %)
Linux x86-64 (callgrind), the whole series against develop, gcc 13 / clang 18:
  canada, indent 4: -13 % / -14 %.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:21 +02:00
Niels Lohmann fdcf3aa390 Do not zero the serializer's buffers
Constructing a serializer value-initialized its three buffers (64 + 512
+ 1024 bytes). Each is written before it is read and only the part that
was written is ever read, so the zeroing was never needed, but it took
about 170 instructions per dump(), more than half of the cost of dumping
a small value.

Apple M1, Apple clang, per dump():
  null                325 -> 153 instructions
  [1,2,3]             629 -> 458 instructions
  small object (134 B) 2795 -> 2624 instructions, 190 -> 165 ns (-13 %)
  a 139-byte object, compact: 2763 -> 2596 instructions (-6 %)
Large documents are unchanged. Checked with valgrind memcheck over the
differential corpus (no use of uninitialized values), and with GCC 13
-Wall -Wextra -Wmaybe-uninitialized at -O0 to -O3 and -Os.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:18 +02:00
Niels Lohmann 209348c151 Escape runs of multi-byte characters straight into the write buffer
With ensure_ascii, every non-ASCII character went through the UTF-8
state machine one byte at a time and its \uXXXX escape was assembled in
string_buffer and copied to the write buffer afterwards.

A run of well-formed multi-byte characters is now decoded and escaped
directly into the write buffer. The first byte that is ASCII or starts an
ill-formed or truncated sequence ends the run and is left to the
unchanged byte-at-a-time path, so error handling and its messages
(positions, replacement, ignore, keep) are as before.

Apple M1, Apple clang, retired instructions per dump(-1, ' ', true):
  cjk text      15.9M -> 6.94M (-56 %)   twitter 9.10M -> 6.16M (-32 %)
  emoji text    8.89M -> 6.08M (-32 %)   latin text 10.3M -> 9.23M (-10 %)
Pure ASCII documents with escapes (logs, JSON in JSON) and all modes
without ensure_ascii are unchanged.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:16 +02:00
Niels Lohmann 7c3888fe42 Write the scalar elements of a container without entering dump_internal()
Every element of an array or object went through dump_internal(), whose
frame holds the state for descending into a container (callee-saved
registers, indentation, depth). A scalar, which is what most elements
are, needs none of it, and paid for the entry and exit regardless.

The loops now call dump_scalar() directly for anything that is not an
object or an array, and dump_internal() for those only.

Apple M1, Apple clang, retired instructions per dump(), on top of the
previous change:
  small object 2997 -> 2712 (-10 %)   signed_ints 276M -> 252M (-9 %)
  floats 323M -> 301M (-7 %)          jeopardy 501M -> 462M (-8 %)
  citm_catalog 5.96M -> 5.61M (-6 %)  twitter 4.40M -> 4.12M (-6 %)
  canada 39.2M -> 37.0M (-5 %)

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:13 +02:00
Niels Lohmann 5bbc7a1098 Write short plain strings and keys in one go in dump()
Most strings in real documents are object keys and short values. For
those, dump_escaped() scanned the bytes, called memcpy with a length
known only at run time, and the surrounding quote, colon and space were
three more bounds-checked writes.

A string of up to 16 printable ASCII characters (no quote, backslash or
DEL) is now read with two overlapping loads, checked with one SWAR test
per word, and written together with its quotes and the colon with two
overlapping stores. Anything else, and a write buffer without room, takes
the general path unchanged. Little-endian targets only; others keep the
general path.

dump_scalar() held the code of every scalar type and saved and restored
all callee-saved registers on every call. It only dispatches now, and the
string, integer, float and binary writers are functions of their own, so
each pays only for what it uses (the string path keeps the check for
long strings in front of any attempt, which keeps documents of long
strings neutral).

Apple M1, Apple clang, retired instructions per dump(), on top of the
printable-ASCII table of #5878:
  citm_catalog  7.54M -> 5.96M (-21 %)   twitter 4.87M -> 4.40M (-10 %)
  jeopardy      603M -> 501M   (-17 %)   logs     8.65M -> 8.17M (-5 %)
  a 139-byte object 3497 -> 2997 (-14 %)   signed_ints -4 %, canada -3 %
Linux x86-64 (callgrind), the whole series against develop, gcc 13 / clang 18:
  citm_catalog -16 % / -19 % (compact), twitter -22 % / -24 %.
Documents of long strings (paths, latin and CJK text) are within 1 %.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-22 09:28:11 +02:00
3 changed files with 1363 additions and 452 deletions

No files matched your search

File diff suppressed because it is too large. Load diff
+636 -226
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -1580,3 +1580,94 @@ TEST_CASE("invalid UTF-8 at every position is handled like the reference")
}
}
}
TEST_CASE("short strings are written like the reference, as values and as keys")
{
// strings of up to 16 characters (32 with vector instructions) that need no
// escaping are written as a whole (together with the quotes and, for a key,
// the colon); everything else, including a string that is merely short,
// takes the general path.
// Every length around the edges of the size classes of the fast path is
// combined with each kind of byte that must send a string to the general
// path, at each position, and compared with the reference for values and,
// via the value, for keys.
const std::vector<std::string> specials =
{
"\"", "\\", "\n", "\x1f", "\x7f", "\xc3\xa9", "\xe2\x82\xac", "\xf0\x9f\x98\x80", "\xff", "\x80", "\xc3"
};
for (std::size_t length = 0; length <= 36; ++length)
{
const std::string plain(length, 'k');
check_dump_against_reference(plain, false);
for (const auto& special : specials)
{
for (std::size_t pos = 0; pos <= length; ++pos)
{
std::string s = plain;
s.insert(pos, special);
check_dump_against_reference(s, false);
for (const bool ensure_ascii :
{
false, true
})
{
// a key and a value are written alike, in compact and
// in pretty-printed form
const json value = s;
const json object = json::object({{s, 1}});
for (const auto error_handler :
{
json::error_handler_t::replace, json::error_handler_t::keep
})
{
const std::string quoted = value.dump(-1, ' ', ensure_ascii, error_handler);
CHECK(object.dump(-1, ' ', ensure_ascii, error_handler) == "{" + quoted + ":1}");
CHECK(object.dump(2, ' ', ensure_ascii, error_handler) == "{\n " + quoted + ": 1\n}");
}
}
}
}
}
}
TEST_CASE("runs of multi-byte characters are escaped like the reference under ensure_ascii")
{
// a run of well-formed multi-byte characters is escaped straight into the
// write buffer; it has to flush that mid-run, stop at an ASCII character or
// an ill-formed sequence and leave those to the general path, and pick up
// behind what the general path has pending
const std::vector<std::string> characters =
{
"\xc2\x80", "\xdf\xbf", "\xe0\xa0\x80", "\xe2\x82\xac", "\xed\x9f\xbf", "\xef\xbf\xbf",
"\xf0\x90\x80\x80", "\xf0\x9f\x98\x80", "\xf4\x8f\xbf\xbf"
};
const std::vector<std::string> interruptions =
{
"", "a", "\n", "\"", "\x7f", "\xff", "\x80", "\xc3", "\xe2\x82", "\xf0\x9f\x98", "\xed\xa0\x80"
};
for (std::size_t shift = 0; shift <= 13; ++shift)
{
for (const auto& interruption : interruptions)
{
for (const std::size_t count :
{
std::size_t{1}, std::size_t{2}, std::size_t{85}, std::size_t{200}
})
{
std::string s(shift, 'x');
for (std::size_t i = 0; i < count; ++i)
{
s += characters[(i + shift) % characters.size()];
}
s += interruption;
s += characters[shift % characters.size()];
s += characters[(shift + 3) % characters.size()];
check_dump_against_reference(s, shift < 2);
}
}
}
}