Skip to content

Read binary strings/blobs in bulk chunks with a memcpy fast path#5233

Merged
nlohmann merged 7 commits into
developfrom
claude/great-moser-6a6c1c
Jul 5, 2026
Merged

Read binary strings/blobs in bulk chunks with a memcpy fast path#5233
nlohmann merged 7 commits into
developfrom
claude/great-moser-6a6c1c

Conversation

@nlohmann

@nlohmann nlohmann commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

The binary reader (binary_reader::get_string / get_binary) read CBOR/MessagePack/BSON/UBJSON strings and byte arrays one byte at a time via get() + push_back(). Worse, the iterator input adapter's get_elements() fallback (input_adapters.hpp) was itself a per-byte loop, so even contiguous inputs (const char*, std::vector/std::string iterators) never benefited from a block copy — get_number's multi-byte reads didn't either.

Changes

  1. iterator_input_adapter::get_elements() contiguous fast path. When the underlying iterator refers to a contiguous range, the whole requested range is copied with a single std::memcpy. Contiguity is detected via std::is_pointer on all standards and, under C++20, additionally via std::contiguous_iterator (so std::vector/std::string iterators qualify too). Non-contiguous iterators keep the element-by-element loop.

  2. get_string() / get_binary() read in bounded chunks. Both now delegate to a shared get_bytes() that pulls data through get_elements() in capped chunks instead of byte by byte. Capping the chunk size preserves the deliberate "don't reserve(len) for an untrusted length" DoS protection (peak allocation stays bounded regardless of the announced length) while turning the inner loop into block copies. The min(chunk_size, len) computation is width-safe, so a narrow length type (e.g. MessagePack ext-8's uint8_t) cannot truncate chunk_size to zero.

Benchmark

Payload: a 2 MiB text string + 2 MiB binary blob + 2000×1 KiB strings, re-encoded to CBOR/MessagePack and decoded 200× (Apple clang, -O2):

Std Case before after speedup
C++20 from_cbor(vector) 20.1 ms 1.0 ms ~20×
C++20 from_cbor(pointer) 18.9 ms 1.0 ms ~19×
C++17 from_cbor(pointer) 18.7 ms 1.0 ms ~19× (memcpy path)
C++17 from_cbor(vector) 18.7 ms 4.0 ms ~4.6× (tight loop, no memcpy pre-C++20)

from_msgpack shows the same pattern.

Correctness

  • Behavior is unchanged: truncated input still throws parse_error.110 at the same byte offset (verified for lengths straddling the chunk boundary).
  • All binary-format unit tests pass (test-cbor, test-msgpack, test-ubjson, test-bson, test-bjdata, test-binary_formats — several million assertions, 0 failures) under C++11.
  • Added a regression test (CBOR large strings and binaries (chunked reader)) covering string/binary roundtrips and truncation at lengths of 0, 1, 4095, 4096, 4097, 8192, and 100000 bytes, for both vector and pointer inputs.
  • single_include regenerated; make check-amalgamation passes.

🤖 Generated with Claude Code

The binary reader read CBOR/MessagePack/BSON/UBJSON strings and byte
arrays one byte at a time via get()/push_back(), and the iterator input
adapter's get_elements() fallback was itself a per-byte loop, so even
contiguous inputs never benefited from a block copy.

Two changes:

1. iterator_input_adapter::get_elements() gains a contiguous fast path
   that copies the whole requested range with std::memcpy. Contiguity is
   detected via std::is_pointer (all standards) and, in C++20,
   std::contiguous_iterator (so std::vector/std::string iterators also
   qualify). Non-contiguous iterators keep the element-by-element loop.

2. get_string()/get_binary() now share get_bytes(), which reads into the
   result in bounded chunks through get_elements() instead of byte by
   byte. Capping the chunk size preserves the deliberate "do not
   reserve(len) for an untrusted length" DoS protection while turning the
   inner loop into block copies. The min(chunk_size, len) computation is
   width-safe so narrow length types (e.g. MessagePack ext-8's uint8_t)
   cannot truncate chunk_size to zero.

Microbenchmark (2 MiB string + 2 MiB blob + 2000x1 KiB strings, Apple
clang, -O2):

  C++20  from_cbor(vector)   20.1 ms -> 1.0 ms  (~20x)
         from_cbor(pointer)  18.9 ms -> 1.0 ms  (~19x)
  C++17  from_cbor(pointer)  18.7 ms -> 1.0 ms  (~19x, memcpy)
         from_cbor(vector)   18.7 ms -> 4.0 ms  (~4.6x, tight loop)

Behavior is unchanged: truncated input still throws parse_error.110 at
the same byte offset, and all binary-format unit tests pass.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread include/nlohmann/detail/input/binary_reader.hpp Fixed
Comment thread include/nlohmann/detail/input/binary_reader.hpp Fixed
Comment thread include/nlohmann/detail/input/binary_reader.hpp Fixed
Comment thread include/nlohmann/detail/input/input_adapters.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
Comment thread single_include/nlohmann/json.hpp Fixed
@github-actions

This comment was marked as outdated.

Comment thread include/nlohmann/detail/input/binary_reader.hpp
Comment thread include/nlohmann/detail/input/binary_reader.hpp Outdated
Comment thread include/nlohmann/detail/input/input_adapters.hpp
Comment thread include/nlohmann/detail/input/input_adapters.hpp Outdated
@nlohmann nlohmann marked this pull request as draft July 4, 2026 06:52
- get_bytes(): replace `len -= static_cast<NumberType>(wanted)` with an
  explicit `len = static_cast<NumberType>(len - ...)`. The compound
  assignment promoted the operands to int and narrowed back, which GCC
  rejects under -Werror=arith-conversion when NumberType is narrower than
  int (e.g. UBJSON's int16 length). This also fixes the CodeQL build.
- get_bytes(): add JSON_ASSERT(read == wanted) documenting that
  get_elements() never returns more than requested (per review).
- iterator_input_adapter: de-duplicate the iterator_is_contiguous
  definition with the #if inside the initializer (per review).
- Add a comment explaining why the memcpy source is &*current (needed for
  non-pointer contiguous iterators), and drop the redundant parentheses.
- Run astyle on the new unit test so the amalgamation/format check passes.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread include/nlohmann/detail/input/input_adapters.hpp Dismissed
Comment thread single_include/nlohmann/json.hpp Dismissed
Flawfinder flags the bare identifier `read` as the POSIX read() syscall
(CWE-120/CWE-20), producing several false-positive code-scanning alerts.
Renaming the local variable removes them; the byte-copy itself is
unchanged and remains bounds-checked (get_elements caps the copy to the
bytes available). This is a pure rename with identical semantics.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nlohmann

nlohmann commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

(Claude Code) Addressed the Flawfinder alerts:

  • The three buffer/read alerts were false positives triggered by a local variable named read (Flawfinder matches it as the POSIX read() syscall). Renamed it to bytes_read — pure rename, identical semantics.
  • The single buffer/memcpy alert is intrinsic to the fast path. The copy is bounds-checked (copied = min(wanted, available)), and it matches the existing unsuppressed std::memcpy uses elsewhere in the library (binary_writer.hpp, to_chars.hpp), so it should be dismissable as a false positive.

Also fixed the GCC -Werror=arith-conversion build failure (narrowing in the chunk-length subtraction for narrow NumberTypes like UBJSON int16) and formatted the new test. All binary-format unit suites pass locally.

nlohmann and others added 2 commits July 4, 2026 11:02
Make the "buffers are large enough" reasoning explicit: before the copy,
assert that `copied` fits both the caller-provided destination (`wanted`
bytes) and the remaining input range (`available` bytes). These hold by
construction (`copied = min(wanted, available)`) but were previously only
implied; the asserts document the invariant and would catch a future
regression that breaks it.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
get_elements() only receives a raw pointer and cannot know the size of
its destination, so the destination-size guarantee is asserted at the
caller that owns the buffer: after resizing `result`, assert it has room
for `wanted` bytes at `old_size`. Together with the `copied <= wanted`
assert at the memcpy, this makes the "destination is large enough"
invariant explicit on both sides of the call.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nlohmann nlohmann marked this pull request as ready for review July 4, 2026 20:47
@nlohmann nlohmann requested a review from gregmarr July 4, 2026 20:47
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Jul 4, 2026
Comment thread include/nlohmann/detail/input/binary_reader.hpp Outdated
std::vector::resize(n) (and string_t's resize) is required to make
size() exactly n; only capacity() is permitted to overshoot. The
destination-size assert in get_bytes should reflect that exact guarantee
rather than a weaker `>=`.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nlohmann nlohmann removed the review needed It would be great if someone could review the proposed changes. label Jul 5, 2026
@nlohmann nlohmann added this to the Release 3.12.1 milestone Jul 5, 2026
…-6a6c1c

Resolves a conflict in include/nlohmann/detail/input/input_adapters.hpp
between this branch's memcpy fast path for get_elements() and develop's
lazy-diagnostics feature (#5234), which added a `begin` iterator member,
`get_consumed_count()`, and `copy_consumed_range()` to the same class.
The two features are orthogonal (lexer.hpp uses the new diagnostics
helpers; binary_reader.hpp uses get_elements()) and both are preserved.
single_include/nlohmann/json.hpp is regenerated from the merged sources.

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

nlohmann commented Jul 5, 2026

Copy link
Copy Markdown
Owner Author

(Claude Code) Merged latest develop to resolve the conflict in include/nlohmann/detail/input/input_adapters.hpp against #5234 (lazy lexer diagnostics), which touched the same iterator_input_adapter class. The two features are orthogonal — the lexer uses the new get_consumed_count()/copy_consumed_range() helpers, this PR uses get_elements() — and both are preserved. Verified locally: all binary-format suites (CBOR/MessagePack/UBJSON/BSON/BJData) plus the lexer/parser/diagnostic-positions suites pass with the merge in place, and make check-amalgamation is clean.

@nlohmann nlohmann merged commit 83c87cb into develop Jul 5, 2026
242 of 243 checks passed
@nlohmann nlohmann deleted the claude/great-moser-6a6c1c branch July 5, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants