Skip to content

Speed up dump(): buffered output and SWAR string escaping#5285

Draft
nlohmann wants to merge 4 commits into
claude/parser-performance-research-4hkdf8from
claude/dump-function-performance-hcj2bl
Draft

Speed up dump(): buffered output and SWAR string escaping#5285
nlohmann wants to merge 4 commits into
claude/parser-performance-research-4hkdf8from
claude/dump-function-performance-hcj2bl

Conversation

@nlohmann

Copy link
Copy Markdown
Owner

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.

Note on the base branch: this is stacked on claude/parser-performance-research-4hkdf8 (#5283), because it reuses that PR's string_scan.hpp scanner and validator. The diff here is only the serializer + tests; once #5283 lands on develop, this can be retargeted to develop unchanged.

The changes (each independent and reversible)

  1. 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-character get()). At a character boundary it now bulk-copies the longest run needing no escaping via string_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). For ensure_ascii == false, string_bulk_run()'s stop set is an exact match for the bytes dump_escaped copies verbatim, so escaping and error handling (including strict-mode error 316 position and message) are identical. Picks up JSON_USE_SIMDUTF for free.

  2. Internal write buffer (devirtualization) — every structural character ({, ", ,, …) previously went straight to the output adapter through a virtual call. All writes now route through put_char/put_chars into a 1 KiB buffer flushed in bulk; the public dump() flushes once the top-level value is serialized (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.

  3. ensure_ascii == true fast path — added find_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 under ensure_ascii), which is why find_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):

dataset speedup
long ASCII strings, ensure_ascii=false 4.2×
long ASCII strings, ensure_ascii=true 4.1×
twitter-like objects 2.7×
pretty-printed objects 2.4×
dense CJK 1.8× (further headroom with JSON_USE_SIMDUTF)
integer arrays 1.1×

Verification

  • Differential: dump output is byte-for-byte identical to the pre-change implementation across ~20k randomized byte strings plus curated edge cases (all escapes, control chars, 0x7F, valid multibyte, surrogates, overlong, truncated sequences), for compact/pretty and object/array output, both ensure_ascii settings, and all three error handlers, in C++11/17/20 at -O2/-O3.
  • New serialization tests cover the write-buffer flush boundaries (>1 KiB strings, 2000-element arrays, 1100-deep nesting), escape and 0x7F handling, multibyte under both settings, and invalid-UTF-8 handling.
  • Warning-clean under clang -Weverything and the gcc pedantic flag set; clang-tidy clean on the changed headers; make check-amalgamation clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XAYM1qhSA2FDaDcGfPW3fG


Generated by Claude Code

nlohmann and others added 2 commits July 20, 2026 22:54
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
nlohmann force-pushed the claude/dump-function-performance-hcj2bl branch from 5cbc06c to 95662c8 Compare July 20, 2026 22:55
nlohmann and others added 2 commits July 20, 2026 23:33
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>
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.

1 participant