Skip to content

Speed up binary writing: value-type output sink + byte-swap number encoding#5286

Draft
nlohmann wants to merge 4 commits into
developfrom
claude/binary-writer-output-sinks
Draft

Speed up binary writing: value-type output sink + byte-swap number encoding#5286
nlohmann wants to merge 4 commits into
developfrom
claude/binary-writer-output-sinks

Conversation

@nlohmann

@nlohmann nlohmann commented Jul 21, 2026

Copy link
Copy Markdown
Owner

What & why

Two related, output-preserving changes that speed up to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson:

  1. Devirtualize the writer. Binary output wrote every byte through output_adapter_t, a shared_ptr whose write_character/write_characters are virtual — plus a make_shared per call. Unlike the lexer (templated on a concrete InputAdapterType, no per-byte indirection), the writer never got that treatment. This templates binary_writer on a concrete OutputSinkType, mirroring the input side's "concrete template fast path + optional virtual interface" split.
  2. Byte-swap number encoding instead of a per-byte std::reverse.

Output is byte-for-byte unchanged on every input; only the path taken to produce it changes.

1. Value-type output sink (devirtualization)

  • output_vector_sink (new, output_adapters.hpp) — appends straight into a std::vector (push_back / insert). No vtable, no shared_ptr; the writes inline. Used by the vector-returning to_* convenience functions.
  • output_adapter_sink (new) — forwards to the type-erased output_adapter_t, so the existing to_*(j, output_adapter) overloads (streams, strings, user adapters) keep working exactly as before: one virtual call each, unchanged.
  • binary_writer gains a defaulted OutputSinkType parameter and writes through oa.write_* instead of oa->write_*; a convenience constructor taking output_adapter_t keeps the adapter overloads untouched. The friend declaration and the binary_writer alias are updated accordingly.

The type-erased output_adapter is retained as the fallback sink — nothing is removed from the public API. (Scope note: the vector-returning convenience path is devirtualized; the to_*(j, target) overloads, streams, and text dump() still use the virtual adapter by design.)

2. Byte-swap number encoding

write_number() reordered multi-byte numbers for the big-endian formats (CBOR/MsgPack/UBJSON) with std::reverse over the byte array. GCC lowered only some sizes to a bswap; clang kept a scalar byte shuffle (0 bswap in the CBOR number path). Replaced with size-dispatched __builtin_bswap16/32/64 helpers (portable shift fallback for other compilers; std::reverse retained for exotic sizes such as a long double number_float_t). The CBOR number path now emits bswap on both compilers (gcc 2→16, clang 0→4).

Portability

Included fixes so the change is warning/sanitizer-clean across the full matrix:

  • GCC -Wduplicated-branches in write_compact_float: for number_float_t == float the two branches are legitimately identical once the concrete sink inlines; silenced for GCC only (clang has no such warning).
  • clang UBSan nonnull-attribute: binary_writer passes (null, 0) for empty string/binary; dropped JSON_HEDLEY_NON_NULL from the sinks so they tolerate the zero-length write exactly as the pre-existing virtual base did.
  • cpplint: added #include <utility> for std::move.
  • nvcc 11.8: reverted the alias-template default argument its front end rejects (the class keeps the defaulted parameter).

Measured gains

-O3, best-of-N, low-noise runner:

workload devirtualization (vs develop) + byte swap (isolated)
scalar-dense binary (integer arrays) ~1.4× gcc +7% / clang +10% (int64); gcc +27% (uint16)
many small to_cbor calls ~1.04× (DOM-traversal bound)
string / blob-heavy output unchanged (bulk-bound) unchanged

No workload regressed. Both wins are concentrated on number/array-heavy encoding; object/string-heavy binary output is bound elsewhere (the std::map DOM walk, string handling), which no writer change touches.

Verification

  • Differential: binary output is byte-for-byte identical to develop across ~3000 randomized values plus curated edge cases (all scalar widths, strings incl. invalid UTF-8, binary, nested arrays/objects) for CBOR, MessagePack, UBJSON (both size/type settings), BJData, and BSON, plus the output_adapter path, in C++11/17/20. All formats round-trip. The byte-swap change is separately verified byte-identical.
  • New sink lines are exercised by the existing binary-format suites (to_cbor(j) → vector sink; to_cbor(j, adapter)/stream → adapter sink; both constructors).
  • Warning-clean under clang -Weverything and the gcc pedantic set (incl. -Wduplicated-branches); clang-tidy clean on the changed headers; cpplint clean; make check-amalgamation clean. __builtin_bswap16 is available on the CI's oldest gcc (4.8).

  • The changes are described in detail, both the what and why.
  • If applicable, an existing issue is referenced. (none)
  • The code coverage remained at 100%. A test case for every new line of code. (new sink/constructor/number-path lines are covered by the existing binary-format suites)
  • If applicable, the documentation is updated. (no public API or option changes)
  • The source code is amalgamated by running make amalgamate.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG

to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson wrote every byte through
output_adapter_t, a shared_ptr<output_adapter_protocol> whose
write_character/write_characters are virtual. Unlike the lexer (templated
on a concrete InputAdapterType), the binary writer never got that
treatment, so binary output paid a vtable lookup per byte and a
make_shared per call.

Template binary_writer on an OutputSinkType and give it two concrete,
non-virtual sinks:

- output_vector_sink: appends straight into a std::vector (push_back /
  insert), used by the vector-returning to_* convenience functions. No
  vtable, no shared_ptr; the writes inline.
- output_adapter_sink: forwards to a type-erased output_adapter_t, so the
  existing to_*(j, output_adapter) overloads (streams, strings, custom
  adapters) keep working exactly as before -- one virtual call each,
  unchanged.

binary_writer keeps a convenience constructor taking output_adapter_t
(building the default output_adapter_sink), so the adapter overloads are
untouched; only the convenience functions switch to the vector sink. The
friend declaration and the basic_json binary_writer alias gain the new
(defaulted) template parameter.

Output is byte-for-byte identical: verified across ~3000 randomized
values plus curated edge cases (all scalar widths, strings with invalid
UTF-8, binary, nested arrays/objects) for CBOR, MessagePack, UBJSON (both
size/type settings), BJData, and BSON, plus the output_adapter path, in
C++11/17/20. Warning-clean under clang -Weverything and the gcc pedantic
set; clang-tidy clean on the changed headers; make check-amalgamation
clean.

Throughput (g++ -O3, vs develop): scalar-dense binary output such as
integer arrays ~1.4x; many small to_cbor calls ~1.04x (DOM traversal
bound); string/blob-heavy output unchanged (already bulk-bound). No
workload regressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@github-actions github-actions Bot added the L label Jul 21, 2026
nlohmann and others added 2 commits July 22, 2026 08:47
Four CI jobs failed on the initial commit; all are addressed here without
changing any output (binary encodings remain byte-for-byte identical to
develop across the differential corpus):

1. ci_test_gcc / cuda (-Werror=duplicated-branches): for number_float_t ==
   float, static_cast<float>(n) is the identity, so write_compact_float's
   two branches are intentionally identical. Once the concrete vector sink
   is inlined, GCC constant-folds and diagnoses this (the type-erased path
   hid it behind a non-inlined virtual call). Silence -Wduplicated-branches
   for GCC (clang has no such warning) alongside the existing -Wfloat-equal
   pragma.

2. ci_static_analysis_clang (UBSan nonnull-attribute): binary_writer passes
   a null pointer with length 0 for empty strings/binary. output_vector_sink
   / output_adapter_sink declared write_characters JSON_HEDLEY_NON_NULL, so
   the sanitizer flagged the (harmless) zero-length call once the sink was
   called directly rather than through the attribute-free virtual base. Drop
   the attribute from both sinks, matching the pre-existing behavior.

3. ci_cpplint (build/include_what_you_use): output_adapter_sink uses
   std::move; add #include <utility>.

4. ci_cuda_example (nvcc 11.8): NVCC's front end rejects the default
   template argument on the binary_writer alias template. Revert the alias
   to its original single-parameter form (relying on binary_writer's own
   defaulted OutputSinkType) and spell out the full type in the vector-sink
   convenience functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
write_number() reordered multi-byte numbers for the big-endian formats
(CBOR/MessagePack/UBJSON) with std::reverse over the byte array. GCC
lowered only some sizes to a bswap; clang kept a scalar byte shuffle
(0 bswap instructions in the CBOR number path). Replace the reverse with
size-dispatched __builtin_bswap16/32/64 helpers (portable shift fallback
for other compilers; std::reverse retained for exotic sizes such as a
long double number_float_t).

Codegen: the CBOR number path now emits bswap on both compilers
(gcc 2 -> 16, clang 0 -> 4). Output is byte-for-byte identical to the
previous implementation across the binary differential corpus.

Throughput (isolated vs the std::reverse version, best of 9):
  CBOR int64 array   gcc +7%   clang +10%
  CBOR uint16 array  gcc +27%  clang flat

Modest but consistent on number-dense encodings; negligible on
string/blob-heavy output, as expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
std::uint16_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
std::uint32_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
std::uint64_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
std::uint16_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
std::uint32_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
std::uint64_t v{};
std::memcpy(&v, a.data(), sizeof(v));
v = byte_swap(v);
std::memcpy(a.data(), &v, sizeof(v));
@nlohmann nlohmann changed the title Devirtualize binary_writer with a value-type output sink Speed up binary writing: value-type output sink + byte-swap number encoding Jul 22, 2026
The vector-returning to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson grew
the output buffer purely by geometric reallocation. Reserving an estimate
up front avoids the early reallocations, which is the dominant per-byte
cost for array/object-heavy output.

The estimate (binary_reserve_hint) is deliberately conservative and safe
against untrusted input: it consults only the top-level element count
(O(1), no walk of the DOM), guards the multiplication against overflow,
and clamps the result to a fixed 1 MiB ceiling, so a large or hostile DOM
can never force an oversized allocation here. The buffer still grows
geometrically past the hint, so an underestimate only costs a few later
reallocations; scalars/strings/binary are written in one shot and get no
hint. Reserving capacity does not change the bytes produced.

Throughput (g++/clang -O3, vs the previous commit):
  cbor int array     +10% / +13%
  cbor object array  +20% / +38%

Output is byte-for-byte identical to develop across the binary
differential corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann
nlohmann force-pushed the claude/binary-writer-output-sinks branch from 487f905 to 9ea2d28 Compare July 22, 2026 11:01
@github-actions

Copy link
Copy Markdown

🔴 Amalgamation check failed! 🔴

The source code has not been amalgamated and/or formatted correctly.

📎 A ready-to-apply patch is attached to the failed workflow run as the amalgamation-patch artifact. Download it, then apply it locally from the repository root with:

git apply amalgamation.patch

This does not require installing astyle yourself.

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.

2 participants