Skip to content

Optimize output vector adapter write - #3569

Merged
nlohmann merged 2 commits into
nlohmann:developfrom
romainreignier:optimize_output_vector_adapter_write
Jul 8, 2022
Merged

nlohmann merged 2 commits into
nlohmann:developfrom
romainreignier:optimize_output_vector_adapter_write

Conversation

@romainreignier

Copy link
Copy Markdown
Contributor

The first commit of this pull request adds a benchmark for the CBOR serialization in order to compare the change introduced by the second commit of the pull request.

In the output_vector_adapter class, replace the usage of std::copy + std::back_inserter by the method std::vector::insert.
The std::back_inserter generates a lot of calls to std::vector::push_back which allocate the memory on the fly.
For big datasets, usually binary data, the overhead is important.
Resizing the vector in a first step and then copying the data helps a lot.
But from my benchmarks, it appears that using the method .insert() on a vector is almost as performant and shorter to write.

I have added a benchmark similar to the .dump() for JSON but using json::to_cbor() but the performance change is not significant.
But because the application in which I have noticed a bottleneck in the use of std::vector::push_back() was serializaing a lot of binary data to cbor, I have added a benchmark serializing vectors of bytes. For this case, the change proposed is significant.

Original:

2022-07-04T14:43:24+02:00
Running ./json_benchmarks
Run on (8 X 3603.48 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x4)
  L1 Instruction 32 KiB (x4)
  L2 Unified 256 KiB (x4)
  L3 Unified 6144 KiB (x1)
Load Average: 0.43, 0.72, 0.85
-----------------------------------------------------------------------------
Benchmark                   Time             CPU   Iterations UserCounters...
-----------------------------------------------------------------------------
BinaryToCbor/8            239 ns          239 ns      2949279 bytes_per_second=107.695M/s
BinaryToCbor/16           264 ns          264 ns      2701934 bytes_per_second=126.646M/s
BinaryToCbor/32           289 ns          289 ns      2419848 bytes_per_second=171.441M/s
BinaryToCbor/64           368 ns          368 ns      1915804 bytes_per_second=217.621M/s
BinaryToCbor/128          502 ns          502 ns      1000000 bytes_per_second=281.356M/s
BinaryToCbor/256          773 ns          773 ns       901424 bytes_per_second=341.569M/s
BinaryToCbor/512         1285 ns         1285 ns       546804 bytes_per_second=395.719M/s
BinaryToCbor/1024        2358 ns         2358 ns       302891 bytes_per_second=422.628M/s
BinaryToCbor/2048        4328 ns         4328 ns       162237 bytes_per_second=455.93M/s
BinaryToCbor/4096        8252 ns         8252 ns        84487 bytes_per_second=475.822M/s
BinaryToCbor/8192       15994 ns        15993 ns        43813 bytes_per_second=489.738M/s
BinaryToCbor/16384      31647 ns        31645 ns        22234 bytes_per_second=494.39M/s
BinaryToCbor/32768      63097 ns        63095 ns        11096 bytes_per_second=495.604M/s

New version with std::vector::insert():

2022-07-04T14:42:37+02:00
Running ./json_benchmarks
Run on (8 X 3786.38 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x4)
  L1 Instruction 32 KiB (x4)
  L2 Unified 256 KiB (x4)
  L3 Unified 6144 KiB (x1)
Load Average: 0.34, 0.76, 0.86
-----------------------------------------------------------------------------
Benchmark                   Time             CPU   Iterations UserCounters...
-----------------------------------------------------------------------------
BinaryToCbor/8            232 ns          232 ns      3067496 bytes_per_second=111.04M/s
BinaryToCbor/16           229 ns          229 ns      3011982 bytes_per_second=145.559M/s
BinaryToCbor/32           230 ns          230 ns      3022206 bytes_per_second=215.89M/s
BinaryToCbor/64           231 ns          231 ns      3037105 bytes_per_second=346.82M/s
BinaryToCbor/128          235 ns          235 ns      2979490 bytes_per_second=601.678M/s
BinaryToCbor/256          236 ns          236 ns      2935424 bytes_per_second=1118.63M/s
BinaryToCbor/512          259 ns          259 ns      2712207 bytes_per_second=1.91596G/s
BinaryToCbor/1024         285 ns          285 ns      2489110 bytes_per_second=3.41833G/s
BinaryToCbor/2048         295 ns          295 ns      2364411 bytes_per_second=6.53373G/s
BinaryToCbor/4096         366 ns          366 ns      1925247 bytes_per_second=10.4859G/s
BinaryToCbor/8192         531 ns          531 ns      1319473 bytes_per_second=14.3938G/s
BinaryToCbor/16384       1042 ns         1042 ns       672042 bytes_per_second=14.6641G/s
BinaryToCbor/32768       1853 ns         1853 ns       380673 bytes_per_second=16.4827G/s

If you prefer an implementation with std::vector::resize() + std::memcpy() or std::copy(), the performance is equivalent.

…adapter::write_characters

This change increases a lot the performance when writing lots of binary data.
@romainreignier
romainreignier requested a review from nlohmann as a code owner July 4, 2022 15:38
@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage remained the same at 100.0% when pulling bb1abe9 on romainreignier:optimize_output_vector_adapter_write into 954b10a on nlohmann:develop.

@nlohmann nlohmann self-assigned this Jul 8, 2022

@nlohmann nlohmann left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

@nlohmann nlohmann added this to the Release 3.11.0 milestone Jul 8, 2022
@nlohmann
nlohmann merged commit d4daaa8 into nlohmann:develop Jul 8, 2022
@nlohmann

nlohmann commented Jul 8, 2022

Copy link
Copy Markdown
Owner

Thanks!

1r0b1n0 pushed a commit to ixblue/rosbridge_server_cpp that referenced this pull request Jul 3, 2023
Apply the change made in the PR#3569 of the json lib
nlohmann/json#3569
nlohmann added a commit that referenced this pull request Oct 1, 2026
…5729)

* Share scalar serialization between dump_internal and dump_value

dump_value()'s cases for string, binary, boolean, number_integer,
number_unsigned, number_float, discarded and null were a byte-for-byte
copy of dump_internal()'s (added together in #5285 for the iterative
fallback path). Any future change to scalar output had to be made in
both places, or the recursive and depth-limited paths would silently
start producing different bytes.

Extract the shared cases into a private dump_scalar() and have both
dump_internal() and dump_value() call it. Output is unchanged: dump(),
dump(4), dump(-1,' ',true) and the replace/ignore error_handler_t
variants are byte-identical over the json_test_data corpus before and
after, and dump() throughput on a scalar-heavy document is unaffected.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Drop serializer.hpp's dependency on binary_writer.hpp

The only use of binary_writer in serializer.hpp was
binary_writer<BasicJsonType, char>::to_char_type() to write the
U+FFFD replacement character's three bytes. With CharType=char this
is an identity conversion, so the include of binary_writer.hpp (and
transitively binary_reader.hpp) pulled in a large, unrelated header
for a no-op call.

Write the three bytes directly instead. serializer.hpp compiles
standalone with -Wall -Wextra -Werror, with and without
-funsigned-char, and unit-serialization's error_handler_t::replace
cases (with and without ensure_ascii) still pass. Moving
binary_writer's to_char_type/to_msgpack_length to its private section
is left as an optional follow-up.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix stale #include lines in the output headers

output_adapters.hpp included <algorithm> and <iterator> for std::copy
and std::back_inserter, which have not been used there since #3569
(2022). serializer.hpp included <algorithm> for std::reverse (also
unused), <cmath> for labs/isnan/signbit (only std::isfinite is used)
and <utility> for std::move (nothing from <utility> is used there),
while using std::next without including <iterator> at all, relying on
getting it transitively through output_adapters.hpp's own stale
<iterator>.

Drop the unused includes, add <iterator> for std::next, and correct
the remaining include comments. Both headers still compile standalone
with -Wall -Wextra -Werror.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Remove JSON_HEDLEY_NON_NULL(2) from write_characters() overrides

output_vector_adapter, output_stream_adapter and output_string_adapter
declared their write_characters(const CharType*, std::size_t) override
JSON_HEDLEY_NON_NULL(2), but binary_writer legitimately calls it with
a null pointer and length 0 for an empty string or binary value; the
type-erased call path only stayed silent under UBSan because the
static callee at those call sites is the unattributed virtual base.
A nonnull attribute on a definition lets GCC and Clang assume the
parameter is non-null inside the function body even when the call is
virtual, so this was latent undefined behavior, not just style.

Drop the attribute from the three overrides and document the
(nullptr, 0) contract on output_adapter_protocol::write_characters.
unit-cbor, unit-msgpack, unit-bson and unit-bon8 (which all exercise
empty binary/string payloads through the stream and vector/string
adapters) pass under -fsanitize=address,undefined,nonnull-attribute.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Update stale serializer doc comments to match the current implementation

dump_internal()'s doc block still described the pre-#5285/#5449
implementation: an escape_string() function that does not exist
(the function is dump_escaped), integer conversion "implicitly via
operator<<" (dump_integer actually uses a digit-pair lookup table),
and floating-point conversion via "%g" (IEEE-754 types go through
to_chars, others through snprintf). dump_value()'s comment said
elements are pushed for dump_internal to walk, but it is
dump_iteratively() that walks the stack. dump_escaped(), dump_integer()
and dump_float() each said they write "to output stream @A o", which
has not been true since the writer moved to write_buffer.

Doc-only change; no behavior, API or ABI impact.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Merge duplicate byte-to-hex helper and drop stale '| 0' promotions

serializer::hex_bytes() and binary_writer::hex_byte() had identical
bodies. Keep one, detail::hex_byte() in string_utils.hpp, and use it
from both. Also drop the `| 0` at the two serializer call sites
(hex_bytes(byte | 0) and hex_bytes(s.back() | 0)): #3088 (7440786)
added it so that `ss << std::hex << (byte | 0)` printed a number
rather than a char with the old stringstream writer; the int result
just narrows back to uint8_t now, so it was a no-op.

Behavior is unchanged: unit-serialization, unit-bon8 (whose
type_error.316 messages exercise this code) and unit-diagnostics
pass, and both headers still compile standalone with -Werror.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Trim two stale lint suppressions in serializer.hpp

dump_integer()'s `auto buffer_ptr = number_buffer.begin();` carried
NOLINT entries for cppcoreguidelines-pro-type-vararg and hicpp-vararg,
left over from the snprintf-based implementation (#3088); there is no
variadic call on that line, so keep only the qualified-auto
suppressions it actually needs. remove_sign()'s assert checked
`x < 0 && x < (std::numeric_limits<number_integer_t>::max)()) `with a
NOLINT(misc-redundant-expression) to hide it; the second conjunct is
always true once x < 0, and has been since 6ce2f35 (2019), so
reduce the assert to `x < 0` and drop the suppression instead of
masking it.

Both are documentation-only changes to assertions/suppressions, not
behavior. The to_chars.hpp `#if 0` branch this item also flagged is
left alone, next to draft PR #5634's pending hunk.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Move the Hoehrmann SPDX copyright line to string_utils.hpp

serializer.hpp carried the SPDX-FileCopyrightText line for Björn
Hoehrmann's UTF-8 decoder, but the decoder (decode() and the utf8d
table) has lived in string_utils.hpp since #5185 (d19f7f5);
serializer.hpp now only calls decode(). Move the copyright line to
where the code it covers actually is.

Part of #5709

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Stop calling std::localeconv() on every dump()

The serializer constructor snapshotted std::localeconv() into a
locale_chars member on every dump(), even though the only reader is
dump_float(number_float_t, std::false_type)'s snprintf path, taken
only for a number_float_t that is neither IEEE single nor double.
localeconv() is not required to be thread-safe with setlocale(), so
every dump() paid for and raced on a lookup that almost never mattered.

Remove locale_chars and the locale member. Right before the
thousands-separator/decimal-point fixups in the snprintf path, read
std::localeconv() into local thousands_sep/decimal_point variables
(null-checked, first byte only, as before) - the same way
lexer::get_decimal_point() already does since #5597. Output is
unchanged unless the locale changes during a single dump(); in that
case the fixups now match what snprintf just produced, instead of a
value snapshotted before the call.

Overlaps draft PR #5608, which touches the same constructor and
dump_float() lines to move this code into a new
dump_float_snprintf(); this lands the lookup change now as #5709 asks,
and #5608 can do the lookup inside dump_float_snprintf() when it
rebases.

Verification: the full json_test_data corpus (742 files, dump(),
dump(4) and dump(-1,' ',true)) is byte-identical to before the change
under the C locale. Added a test pinning the new per-conversion
lookup: it switches LC_NUMERIC mid-dump() (via a streambuf that
switches on its first write, after the serializer's write buffer has
been flushed once but before a later float is converted) and checks
the decimal point is still normalized using the locale active at
conversion time. On a platform where long double is IEEE-754 double
(e.g. 64-bit Arm), dump_float() takes the locale-independent
to_chars() path and the test is a no-op there; it is meaningful on a
platform where long double is extended precision (most x86 targets).

Part of #5709 item 3

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

⌂ 换网址