Speed up binary writing: value-type output sink + byte-swap number encoding#5286
Draft
nlohmann wants to merge 4 commits into
Draft
Speed up binary writing: value-type output sink + byte-swap number encoding#5286nlohmann wants to merge 4 commits into
nlohmann wants to merge 4 commits into
Conversation
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>
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)); |
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
force-pushed
the
claude/binary-writer-output-sinks
branch
from
July 22, 2026 11:01
487f905 to
9ea2d28
Compare
🔴 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 git apply amalgamation.patchThis does not require installing astyle yourself. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Two related, output-preserving changes that speed up
to_cbor/to_msgpack/to_ubjson/to_bjdata/to_bson:output_adapter_t, ashared_ptrwhosewrite_character/write_charactersare virtual — plus amake_sharedper call. Unlike the lexer (templated on a concreteInputAdapterType, no per-byte indirection), the writer never got that treatment. This templatesbinary_writeron a concreteOutputSinkType, mirroring the input side's "concrete template fast path + optional virtual interface" split.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 astd::vector(push_back/insert). No vtable, noshared_ptr; the writes inline. Used by the vector-returningto_*convenience functions.output_adapter_sink(new) — forwards to the type-erasedoutput_adapter_t, so the existingto_*(j, output_adapter)overloads (streams, strings, user adapters) keep working exactly as before: one virtual call each, unchanged.binary_writergains a defaultedOutputSinkTypeparameter and writes throughoa.write_*instead ofoa->write_*; a convenience constructor takingoutput_adapter_tkeeps the adapter overloads untouched. The friend declaration and thebinary_writeralias are updated accordingly.The type-erased
output_adapteris retained as the fallback sink — nothing is removed from the public API. (Scope note: the vector-returning convenience path is devirtualized; theto_*(j, target)overloads, streams, and textdump()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) withstd::reverseover the byte array. GCC lowered only some sizes to abswap; clang kept a scalar byte shuffle (0bswapin the CBOR number path). Replaced with size-dispatched__builtin_bswap16/32/64helpers (portable shift fallback for other compilers;std::reverseretained for exotic sizes such as along doublenumber_float_t). The CBOR number path now emitsbswapon both compilers (gcc 2→16, clang 0→4).Portability
Included fixes so the change is warning/sanitizer-clean across the full matrix:
-Wduplicated-branchesinwrite_compact_float: fornumber_float_t == floatthe two branches are legitimately identical once the concrete sink inlines; silenced for GCC only (clang has no such warning).nonnull-attribute: binary_writer passes(null, 0)for empty string/binary; droppedJSON_HEDLEY_NON_NULLfrom the sinks so they tolerate the zero-length write exactly as the pre-existing virtual base did.#include <utility>forstd::move.Measured gains
-O3, best-of-N, low-noise runner:to_cborcallsNo workload regressed. Both wins are concentrated on number/array-heavy encoding; object/string-heavy binary output is bound elsewhere (the
std::mapDOM walk, string handling), which no writer change touches.Verification
developacross ~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 theoutput_adapterpath, in C++11/17/20. All formats round-trip. The byte-swap change is separately verified byte-identical.to_cbor(j)→ vector sink;to_cbor(j, adapter)/stream → adapter sink; both constructors).-Weverythingand the gcc pedantic set (incl.-Wduplicated-branches); clang-tidy clean on the changed headers; cpplint clean;make check-amalgamationclean.__builtin_bswap16is available on the CI's oldest gcc (4.8).make amalgamate.🤖 Generated with Claude Code
https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG