Scan the get area of an input stream in bulk

Parsing from a std::istream read one character at a time through the
streambuf, so none of the parser's fast paths applied: it was 1.8 to 2.5
times slower than reading the file into a string and parsing that.

input_stream_adapter now exposes the streambuf's get area (gptr() to
egptr()) to the lexer as a window. The protected get-area members are
reached through member pointers formed in a class derived from
std::streambuf, which is ordinary, standard-conforming member access. The
lexer scans the window with the same bulk paths as a string, and asks
the streambuf for the next character as before when the window is used
up. Nothing is read ahead, copied, or put back, so the stream is left
exactly where it was left before, with and without
JSON_PRECISE_STREAM_POSITION, and a streambuf without a get area is still
read one character at a time.

The adapter also keeps the current token for error messages as a pointer
into the get area, and copies it out only before the get area is
refilled, instead of the lexer copying every character.

On an Apple M1 Max (Apple Clang, -O3), parsing from an std::ifstream is
1.6 to 2.1 times as fast (twitter.json 4.08 -> 2.30 ms, citm_catalog.json
8.75 -> 4.08 ms), and reading 100,000 small values with operator>> 1.4
(objects) to 4.0 (integers) times as fast. With GCC 13 and libstdc++ on
x86-64 Linux, std::ifstream is 1.5 to 2.2 times as fast. Parsing from a
stream is now within 11 to 18 % of reading the file into a string first.

JSON_DISABLE_STREAM_BULK_SCAN restores reading one character at a time,
for streambufs that do not follow the usual get-area contract. The new
tests compare every observable result (values, exception messages, stream
state, remaining characters) for get areas of 1 to 4096 bytes against a
streambuf without a get area, and run again with the macro and with
JSON_PRECISE_STREAM_POSITION.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This commit is contained in:
Niels Lohmann committed 2026-10-14 17:26:38 +02:00
1 parent 80d00eda50
commit d5631dccbd
18 files changed
+1177 -20

No files matched your search

+238 -9
View File
@@ -3238,6 +3238,10 @@ JSON_HEDLEY_DIAGNOSTIC_POP
#define JSON_DISABLE_TUPLE_REFERENCE_CONVERSION 0
#endif
#ifndef JSON_DISABLE_STREAM_BULK_SCAN
#define JSON_DISABLE_STREAM_BULK_SCAN 0
#endif
#if JSON_HAS_THREE_WAY_COMPARISON
#include <compare> // partial_ordering
#endif
@@ -7974,10 +7978,12 @@ NLOHMANN_JSON_NAMESPACE_END
#include <cstdint> // uint32_t
#include <cstring> // strlen
#include <iterator> // begin, end, iterator_traits, random_access_iterator_tag, distance, next, reverse_iterator
#include <limits> // numeric_limits
#include <streambuf> // streambuf
#include <string> // string, char_traits
#include <type_traits> // enable_if, is_base_of, is_pointer, is_integral, remove_pointer
#include <utility> // pair, declval
#include <vector> // vector
#ifndef JSON_NO_IO
#include <cstdio> // FILE *
@@ -7995,6 +8001,7 @@ NLOHMANN_JSON_NAMESPACE_END
// #include <nlohmann/detail/string_utils.hpp>
NLOHMANN_JSON_NAMESPACE_BEGIN
namespace detail
{
@@ -8092,15 +8099,26 @@ class input_stream_adapter
input_stream_adapter& operator=(input_stream_adapter&) = delete;
input_stream_adapter& operator=(input_stream_adapter&&) = delete;
#if JSON_PRECISE_STREAM_POSITION
input_stream_adapter(input_stream_adapter&& rhs) noexcept
: is(rhs.is), sb(rhs.sb), lookahead(rhs.lookahead)
: is(rhs.is), sb(rhs.sb)
#if JSON_PRECISE_STREAM_POSITION
, lookahead(rhs.lookahead)
#endif
#if !JSON_DISABLE_STREAM_BULK_SCAN
, token_carry(std::move(rhs.token_carry))
, token_segment(rhs.token_segment)
, current_in_get_area(rhs.current_in_get_area)
, tracking_token(rhs.tracking_token)
#endif
{
rhs.is = nullptr;
rhs.sb = nullptr;
#if JSON_PRECISE_STREAM_POSITION
rhs.lookahead = false;
#endif
}
#if JSON_PRECISE_STREAM_POSITION
// Whether the character last returned by get_character() can be given back
// to the input with release_lookahead().
static constexpr bool supports_lookahead = true;
@@ -8120,17 +8138,41 @@ class input_stream_adapter
sb->sbumpc();
}
#if !JSON_DISABLE_STREAM_BULK_SCAN
// sgetc() refills the get area if it is exhausted
const bool refill = streambuf_access::get_ptr(sb) == streambuf_access::get_end(sb);
if (JSON_HEDLEY_UNLIKELY(refill))
{
save_token_segment();
}
#endif
auto res = sb->sgetc();
// set eof manually, as we don't use the istream interface.
if (JSON_HEDLEY_UNLIKELY(res == std::char_traits<char>::eof()))
{
// there is nothing to step over next time
lookahead = false;
#if !JSON_DISABLE_STREAM_BULK_SCAN
current_in_get_area = false;
#endif
is->clear(is->rdstate() | std::ios::eofbit);
}
else
{
lookahead = true;
#if !JSON_DISABLE_STREAM_BULK_SCAN
if (JSON_HEDLEY_UNLIKELY(refill))
{
// the peeked character is at gptr(), unless the streambuf has
// no get area
after_refill(res, streambuf_access::get_ptr(sb) != streambuf_access::get_end(sb));
}
else
{
current_in_get_area = true;
}
#endif
}
return res;
}
@@ -8144,13 +8186,6 @@ class input_stream_adapter
lookahead = false;
}
#else
input_stream_adapter(input_stream_adapter&& rhs) noexcept
: is(rhs.is), sb(rhs.sb)
{
rhs.is = nullptr;
rhs.sb = nullptr;
}
// std::istream/std::streambuf use std::char_traits<char>::to_int_type, to
// ensure that std::char_traits<char>::eof() and the character 0xFF do not
// end up as the same value, e.g., 0xFFFFFFFF.
@@ -8159,12 +8194,37 @@ class input_stream_adapter
// stays consumed after parsing; see JSON_PRECISE_STREAM_POSITION.
std::char_traits<char>::int_type get_character()
{
#if !JSON_DISABLE_STREAM_BULK_SCAN
// sbumpc() refills the get area if it is exhausted
const bool refill = streambuf_access::get_ptr(sb) == streambuf_access::get_end(sb);
if (JSON_HEDLEY_UNLIKELY(refill))
{
save_token_segment();
}
#endif
auto res = sb->sbumpc();
// set eof manually, as we don't use the istream interface.
if (JSON_HEDLEY_UNLIKELY(res == std::char_traits<char>::eof()))
{
#if !JSON_DISABLE_STREAM_BULK_SCAN
current_in_get_area = false;
#endif
is->clear(is->rdstate() | std::ios::eofbit);
}
#if !JSON_DISABLE_STREAM_BULK_SCAN
else if (JSON_HEDLEY_UNLIKELY(refill))
{
// the consumed character is at gptr() - 1, unless the streambuf
// has no get area and returned it from uflow()
const char* const next = streambuf_access::get_ptr(sb);
after_refill(res, next != nullptr && next != streambuf_access::get_begin(sb));
}
else
{
current_in_get_area = true;
}
#endif
return res;
}
#endif
@@ -8183,7 +8243,165 @@ class input_stream_adapter
return res;
}
#if !JSON_DISABLE_STREAM_BULK_SCAN
// The lexer may scan the characters in the get area of the streambuf
// directly (SWAR fast paths). The get area is only a window into the
// input: it ends wherever the streambuf's buffer ends, and is refilled by
// the next get_character(). The lexer therefore keeps counting the
// position itself and treats the end of the window as "unknown", not as
// the end of the input.
static constexpr bool supports_bulk_scan = true;
static constexpr bool bulk_window = true;
// Pointer to the next unread character; only valid when
// bulk_remaining() > 0. The character before it is the one last returned
// by get_character().
const char* bulk_data() const
{
return current_end();
}
// Number of unread characters in the get area.
std::size_t bulk_remaining() const
{
if (!current_in_get_area)
{
return 0;
}
return static_cast<std::size_t>(streambuf_access::get_end(sb) - current_end());
}
// Consume @a n characters previously inspected via bulk_data(); the last
// of them becomes the character last returned by get_character().
void bulk_skip(std::size_t n)
{
JSON_ASSERT(n <= bulk_remaining());
// gbump() takes an int, but a get area (e.g., of a std::stringbuf) can
// be larger
constexpr auto max_bump = static_cast<std::size_t>((std::numeric_limits<int>::max)());
for (; n > max_bump; n -= max_bump)
{
streambuf_access::bump(sb, static_cast<int>(max_bump));
}
streambuf_access::bump(sb, static_cast<int>(n));
}
// The adapter keeps the current token for the lexer's error messages: the
// part in the get area is only remembered as a pointer, and copied out
// when the get area is about to be refilled.
static constexpr bool supports_token_tracking = true;
// Start a token with the characters read from now on. Until this is
// called, nothing is tracked (the binary readers never call it).
void begin_token() noexcept
{
tracking_token = true;
token_carry.clear();
token_segment = current_end();
}
// Start a new token with the character @a c last returned by
// get_character().
void mark_token_start(std::char_traits<char>::int_type c)
{
tracking_token = true;
token_carry.clear();
if (current_in_get_area)
{
token_segment = current_end() - 1;
}
else
{
token_segment = nullptr;
token_carry.push_back(std::char_traits<char>::to_char_type(c));
}
}
// Copy the characters read since mark_token_start() to @a out, without the
// last one if @a drop_last.
void copy_token(std::vector<char>& out, bool drop_last) const
{
out = token_carry;
if (token_segment != nullptr)
{
out.insert(out.end(), token_segment, current_end());
}
if (drop_last && !out.empty())
{
out.pop_back();
}
}
#endif
private:
#if !JSON_DISABLE_STREAM_BULK_SCAN
// Reads the protected get-area pointers of any std::streambuf. Naming the
// members through a derived class makes forming the member pointers
// accessible; calling them on another streambuf object is then ordinary
// member access through a pointer to member.
struct streambuf_access : std::streambuf
{
static const char* get_ptr(std::streambuf* s)
{
return (s->*(&streambuf_access::gptr))();
}
static const char* get_end(std::streambuf* s)
{
return (s->*(&streambuf_access::egptr))();
}
static const char* get_begin(std::streambuf* s)
{
return (s->*(&streambuf_access::eback))();
}
static void bump(std::streambuf* s, int n)
{
(s->*(&streambuf_access::gbump))(n);
}
};
// one past the character last returned by get_character(), if that is in
// the get area
const char* current_end() const
{
#if JSON_PRECISE_STREAM_POSITION
// the character is peeked at gptr() and not stepped over yet, unless
// it was given back with release_lookahead()
return streambuf_access::get_ptr(sb) + (lookahead ? 1 : 0);
#else
return streambuf_access::get_ptr(sb);
#endif
}
// the get area is about to be replaced: copy the token's part in it
void save_token_segment()
{
if (token_segment != nullptr)
{
token_carry.insert(token_carry.end(), token_segment, streambuf_access::get_end(sb));
token_segment = nullptr;
}
}
// character @a c was just read into a new get area (@a in_get_area) or
// returned without one
void after_refill(std::char_traits<char>::int_type c, bool in_get_area)
{
current_in_get_area = in_get_area;
if (!tracking_token)
{
return;
}
if (in_get_area)
{
token_segment = current_end() - 1;
}
else
{
token_carry.push_back(std::char_traits<char>::to_char_type(c));
}
}
#endif
#if JSON_PRECISE_STREAM_POSITION
// Step over the character last returned by get_character(). The character
// has already been peeked successfully, so for every streambuf with a get
@@ -8205,6 +8423,16 @@ class input_stream_adapter
/// whether get_character() peeked a character that is not consumed yet
bool lookahead = false;
#endif
#if !JSON_DISABLE_STREAM_BULK_SCAN
/// the part of the current token that was in earlier get areas
std::vector<char> token_carry{}; // NOLINT(readability-redundant-member-init)
/// start of the current token's part in the get area, or nullptr
const char* token_segment = nullptr;
/// whether the character last returned by get_character() is in the get area
bool current_in_get_area = false;
/// whether a token is tracked at all; the binary readers never start one
bool tracking_token = false;
#endif
};
#endif // JSON_NO_IO
@@ -36247,6 +36475,7 @@ struct formatter<nlohmann::NLOHMANN_BASIC_JSON_TPL, char> // NOLINT(cert-dcl58-c
#undef JSON_NO_UNIQUE_ADDRESS
#undef JSON_DISABLE_ENUM_SERIALIZATION
#undef JSON_DISABLE_TUPLE_REFERENCE_CONVERSION
#undef JSON_DISABLE_STREAM_BULK_SCAN
#ifndef JSON_TEST_KEEP_MACROS
#undef JSON_CATCH