Speed up dump(): buffered output and SWAR string escaping#5285
Draft
nlohmann wants to merge 4 commits into
Draft
Speed up dump(): buffered output and SWAR string escaping#5285nlohmann wants to merge 4 commits into
nlohmann wants to merge 4 commits into
Conversation
When ensure_ascii is false, dump_escaped previously ran every byte of every string and object key through the UTF-8 DFA decoder, even for the common case of ordinary text with nothing to escape. This mirrors the per-byte cost the parser had before the contiguous fast paths. At a character boundary, bulk-copy the longest run of bytes that need no escaping using string_bulk_run() - the same SWAR scanner and UTF-8 bulk validator the lexer's contiguous path uses - and only fall back to the byte-at-a-time DFA loop for the first byte that needs individual handling (a quote, backslash, control character, or ill-formed/truncated UTF-8). Because every "hard" or invalid byte is still processed by the unchanged byte path, escaping output and error handling (including strict-mode error 316 position and message) are byte-identical to before. The ensure_ascii=true path is unchanged: it must escape non-ASCII and 0x7F, which string_bulk_run does not stop on, so a separate predicate would be needed for it. Verified byte-for-byte identical dump output against the pre-change implementation across ~20k randomized byte strings plus curated edge cases (all escapes, control chars, valid multibyte, surrogates, overlong, truncated sequences) for both ensure_ascii settings and all three error handlers, in C++11/17/20 at -O2/-O3. Throughput (g++ -O3, ensure_ascii=false, vs pre-change): long ASCII strings 4.2x twitter-like objects 2.3x dense CJK 1.4x (further headroom with JSON_USE_SIMDUTF) 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>
Two further serialization speedups on top of the ensure_ascii=false bulk
copy, both reusing the SWAR primitives in detail/input/string_scan.hpp.
1. Internal write buffer (devirtualization). Every structural character
('{', '"', ',', ...) previously went straight to the output adapter
through a virtual call. Route all writes through put_char/put_chars
into a 1 KiB buffer that flushes in bulk; the public dump() flushes
once the top-level value is done (the recursive worker is split out as
dump_internal). Runs larger than the buffer are written straight
through, so large payloads are not copied twice. This is the dominant
cost for object/array-heavy values.
2. ensure_ascii fast path. dump_escaped previously ran the UTF-8 DFA over
every byte when escaping non-ASCII. Add find_ascii_copyable_run() (a
SWAR scan stopping at '"', '\\', < 0x20, 0x7F, and >= 0x80) so runs of
printable ASCII are bulk-copied, with the byte path handling each
escape/non-ASCII byte exactly as before.
Behavior is unchanged: dump output is byte-for-byte identical to the
previous implementation across ~20k randomized byte strings plus curated
edge cases (all escapes, control chars, 0x7F, valid multibyte,
surrogates, overlong, truncated), for object/array/pretty output, both
ensure_ascii settings, and all three error handlers, in C++11/17/20 at
-O2/-O3. New unit tests cover the buffer flush boundaries, the escape and
0x7F handling, multibyte under both settings, and invalid-UTF-8 handling.
Throughput (g++ -O3, vs the ensure_ascii=false-only baseline):
long ASCII, ensure_ascii=0 4.2x
long ASCII, ensure_ascii=1 4.1x
twitter-like objects 2.7x
dense CJK 1.8x
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/dump-function-performance-hcj2bl
branch
from
July 20, 2026 22:55
5cbc06c to
95662c8
Compare
test-convenience failed (macOS finished first; the failure is platform-independent) because check_escaped() calls the internal serializer::dump_escaped() directly and then reads the output stream. Since dump_escaped() now writes into the serializer's internal write buffer, the bytes were still buffered and the stream was empty. Expose flush() under JSON_PRIVATE_UNLESS_TESTED (same visibility as dump_escaped) and flush in check_escaped() before inspecting the output. Per-string flushing inside dump_escaped() was rejected on purpose: it would defeat the buffering that makes object/array-heavy dumps faster. Library behavior is unchanged (flush()'s body is identical; only its access label moved). 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>
The "many small structural writes exceed the write buffer" subcase built a 1100-deep nested array and dumped it to force >1024 consecutive single-character writes through put_char (exercising the write buffer's flush-when-full branch). dump() recurses per nesting level, so on MSVC debug builds (smaller default stack, larger frames) this overflowed the stack and crashed test-serialization; Linux/macOS have enough headroom to hide it. Replace the nesting with a flat array of 500 empty strings. Each element emits '"', '"', ',' via put_char, so the dump is a long run of single-character writes (1501 bytes > the 1024-byte buffer) at nesting depth two, hitting the same flush branch without deep recursion. Library code is unchanged. 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>
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
dump()had two hot spots that mirror exactly what #5283 fixed on the parse side, so this PR reuses that PR's SWAR/UTF-8 primitives (detail/input/string_scan.hpp) to fix the serializer. Output is byte-for-byte unchanged on every input; only the path taken to produce it is faster.The changes (each independent and reversible)
SWAR bulk string escaping (
ensure_ascii == false) —dump_escaped()ran the UTF-8 DFA over every byte of every string and key, even for ordinary text with nothing to escape (the serializer twin of the parser's per-characterget()). At a character boundary it now bulk-copies the longest run needing no escaping viastring_bulk_run()— the same scanner + UTF-8 bulk validator the lexer's contiguous path uses — and drops to the byte-at-a-time DFA only for the first byte that needs individual handling (a quote, backslash, control char, or ill-formed/truncated UTF-8). Forensure_ascii == false,string_bulk_run()'s stop set is an exact match for the bytesdump_escapedcopies verbatim, so escaping and error handling (including strict-mode error 316 position and message) are identical. Picks upJSON_USE_SIMDUTFfor free.Internal write buffer (devirtualization) — every structural character (
{,",,, …) previously went straight to the output adapter through a virtual call. All writes now route throughput_char/put_charsinto a 1 KiB buffer flushed in bulk; the publicdump()flushes once the top-level value is serialized (the recursive worker is split out asdump_internal). Runs larger than the buffer are written straight through, so large payloads are not copied twice. This is the dominant cost for object/array-heavy values.ensure_ascii == truefast path — addedfind_ascii_copyable_run()(a SWAR scan stopping at",\,< 0x20,0x7F, and>= 0x80) so runs of printable ASCII are bulk-copied even when non-ASCII must be\u-escaped.0x7F/DEL is a deliberate stop (it is escaped underensure_ascii), which is whyfind_string_special()could not be reused directly here.Measured gains
json::dump(), C++17, g++ 13-O3, vs the pre-change serializer (representative synthetic data):ensure_ascii=falseensure_ascii=trueJSON_USE_SIMDUTF)Verification
0x7F, valid multibyte, surrogates, overlong, truncated sequences), for compact/pretty and object/array output, bothensure_asciisettings, and all three error handlers, in C++11/17/20 at-O2/-O3.0x7Fhandling, multibyte under both settings, and invalid-UTF-8 handling.-Weverythingand the gcc pedantic flag set; clang-tidy clean on the changed headers;make check-amalgamationclean.make amalgamate.🤖 Generated with Claude Code
https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG
Generated by Claude Code