Skip to content

perf: in-place to_json, integer-valued double fast path, SWAR integer parsing, in-register escape handling - #2799

Open
FranciscoThiesen wants to merge 3 commits into
masterfrom
francisco/serdes-perf
Open

perf: in-place to_json, integer-valued double fast path, SWAR integer parsing, in-register escape handling#2799
FranciscoThiesen wants to merge 3 commits into
masterfrom
francisco/serdes-perf

Conversation

@FranciscoThiesen

@FranciscoThiesen FranciscoThiesen commented Jul 30, 2026

Copy link
Copy Markdown
Member

Three independent optimizations to the reflection serializer and on-demand integer parsing. Output is byte-identical to master in every benchmark and fuzz case; each change is motivated by a profile and ships with a dedicated correctness harness.

Rebased on current master (#2795, #2798, #2804, #2805 all merged since the first revision; everything below is re-measured against b3072d2, 5 alternating co-built rounds, medians). Following the escaper comparison in the comments and the interest there, this PR now also carries a fourth change: the in-register escape handling grafted into #2795's escape_block structure. Details in section 4.

benchmark (benchmark/static_reflect) master this PR
twitter serialize, reused string_builder 13,757 MB/s 14,696 MB/s +6.8%
twitter to_json(std::string&), reused string 8,343 12,359 +48.1%
twitter to_json(std::string&), fresh string 8,360 9,039 +8.1%
citm to_json(std::string&), reused string 5,502 6,458 +17.4%
citm deserialize (doc.get<T>()) 3,352 3,551 +5.9%
integer-valued doubles (micro) 650 3,153 +385%
escape-heavy strings, 1 escape / 16 B (micro) 980-1,143 1,475-1,796 +32-59%

gcc 16.1, aarch64/NEON, -O3. Baseline and patched trees co-built in one container, 5 alternating rounds, medians (same-invocation A/B only; cross-run numbers drift on this hardware). twitter deserialize and the reused-string_builder serialization paths are unchanged (±noise).


1. to_json(value, std::string&) builds in place

Profile of to_json on twitter (taken before #2795; the copy share only grows with the faster escaper): ~43% of the time goes to copying or managing bytes that were already produced:

write_string_escaped  █████████████████░░░░░░░░░  41%
memcpy / growth       ███████████░░░░░░░░░░░░░░░  28%   ← this change
to_json wrapper       ██████░░░░░░░░░░░░░░░░░░░░  15%   ← this change

The old implementation builds in a private buffer (doubling from 1 KB, each doubling a new[]+memcpy), then copies the whole result again into the destination:

// before: every output byte written ~3x
string_builder b(initial_capacity);
append(b, z);
s.assign(view);          // full copy of the output

// after: the destination's storage is the build buffer
string_builder b(s, initial_capacity);   // new string-backed mode
append(b, z);
s.resize(view.size());   // shrink, no copy; capacity kept for the next call

A caller that reuses the string pays zero allocations and zero copies, which is the common production pattern. to_json_string builds into a local and move-returns. The owned mode is untouched on its hot path (the two new members are cold; a cached buf pointer keeps codegen branch-free).

Caveats: destination must not alias the serialized value (documented); under -fno-exceptions, std::string growth failure aborts exactly as it would in caller code.

2. Integer-valued doubles take the integer writer

double fields holding exact integers (ids, counts, epoch timestamps) are everywhere in real JSON, and the shortest round-trip formatting of such a value below 10^15 is just its integer digits plus ".0":

if (d > -1.0e15 && d < 1.0e15) {          // false for NaN, so the cast is safe
  int64_t iv = static_cast<int64_t>(d);
  if (static_cast<double>(iv) == d && !(iv == 0 && std::signbit(d))) {
    p = internal::write_uint_jeaiii(p, mag);   // ~5x faster than the float path
    *p++ = '.'; *p++ = '0';

Previously ~2.4× behind yyjson on this data class, now slightly ahead. Full-precision doubles pay 1–2% for the check.

The bound is 10^15, not 2^53: to_chars switches to scientific notation at 16 significant digits. An exhaustive identity sweep (2,403,929 cases: powers of 2/10, trailing-zero grids, the ±2^53 band, random integer-valued doubles) proves the fast path byte-identical to to_chars over the whole gate. The sweep caught an earlier 2^53 version of this patch and is worth keeping as a test.

Bundled fix: with SIMDJSON_ENABLE_NAN_INF=0 (the default), append(NaN) used to reach to_chars and emit 2.696539702293474e+308, valid JSON with silently wrong data. It now emits null. Happy to switch to returning an error if preferred.

3. SWAR fast path for get_int64 / get_uint64

Profile of citm deserialize (baseline): stage1 40%, struct deserialization 32%, and inside the latter, digit-at-a-time integer parsing. The code declines SWAR with a comment: “we don't use is_made_of_eight_digits_fast because large integers like 123456789 are rare.” In id-heavy documents they are the norm:

// gate = the validity check itself: one 8-byte load (in bounds via
// SIMDJSON_PADDING); short numbers fall through to the byte loop unchanged
if (simdjson_likely(!is_made_of_eight_digits_fast(src))) { return parse_unsigned(src); }
uint64_t i = parse_eight_digits_unrolled(src);           // 8 digits per step
if (is_made_of_eight_digits_fast(src + 8)) { i = i * 100000000 + ...; }

9/16/19-digit integers: +49% / +101% / +81% in isolation; citm deserialize +5.8%; twitter neutral. On targets that set SIMDJSON_SWAR_NUMBER_PARSING to 0 (big-endian, where parse_eight_digits_unrolled is not safe) both functions fall back to the byte loop. Trade-off, disclosed: documents of exclusively 2-5-digit integers measure −8 to −13% on the gate in a synthetic micro; both real-document benchmarks are net positive or neutral.

Correctness: a differential fuzz (15,528 adversarial cases: int64/uint64 boundaries ±1, 20-digit overflows, leading zeros, adjacent numbers straddling the 16-byte window, non-minified spacing) produces bit-identical (value, error) pairs against baseline.


4. Escape-heavy blocks are consumed in registers

Follow-up to the escaper comparison in the comments (and thanks @fior512 for the offer to help; this is the merge of the two approaches). #2795's structure is unchanged for clean blocks and sub-16-byte strings. When a block does contain escapes, it is now blind-stored and its whole mask is consumed in registers: after each escape expansion the remaining lanes are repositioned with a shuffle (vqtbl1q_u8 on NEON, a one-time spill on SSE2) instead of escape_block's per-run copies.

escape_store16(out, word);                    // clean prefix lands in place
out = escape_block_hot(word, src, out, i, escape_bitmask(flags));
// hot: per escape, emit + reposition the tail from the register; one load
// and one store per 16 input bytes at any escape density

Escape-heavy strings gain +32-59% at every length (one escape per 16 bytes); twitter serialization gains ~6% across the builder variants. Disclosed trade-off: long strings with sparse escapes (one per ~333 bytes) measure 5-11% slower in a synthetic sweep, because the out-of-line hot call re-materializes the vector constants per escape-bearing block; documents like twitter/citm come out ahead (see table). I explored keeping the hot path inline plus a length split (four variants measured); each traded a different band. Happy to share that data in a follow-up if there is appetite for per-block strategy selection.

The blind store needs one vector of slack past the worst-case expansion, so the escape capacity checks move from 2 + 6*len to 6*len + 18 (and analogues at all five call sites); escape_tight_capacity_boundary exercises exactly those budgets under ASan, alongside #2804's exhaustive escaper test which also passes.

Testing. The second commit adds escape_tight_capacity_boundary to builder_string_builder_tests: dense escape patterns at every block-boundary length/offset, built with initial_capacity = 1 so every growth lands on an exact pos+needed boundary, byte-compared against a scalar reference. It guards the #2795 escaper's capacity handling and passes under ASan. The other harnesses (double-format identity sweep, number-parse differential fuzz) are standalone; glad to contribute them as unit tests too.

Scope. All numbers are gcc 16 / aarch64; x86 validation welcome. The three changes are independent, happy to split if easier to review.

@FranciscoThiesen

FranciscoThiesen commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Rebased on current master. #2795 merged a single-pass escaper while this PR was open — it is notably faster than the escaper rewrite this PR originally carried (especially on short strings), so that change is dropped and all numbers in the description are re-measured against #2795. Two artifacts from that work remain useful and are included/available:

  • The second commit's escape_tight_capacity_boundary test now guards the Faster write_string_escaped: single-pass block escaping (SSE2, NEON) #2795 escaper: dense escape patterns at every block-boundary length/offset with initial_capacity = 1 builders, byte-compared against a scalar reference; passes (incl. under ASan) on current master.
  • In an isolated head-to-head, a blind-store escaper variant that repositions the chunk tail in registers measured +44–71% over escape_block at moderate escape densities (one escape per 16–64 bytes — e.g. JSON-in-JSON payloads), at parity on clean input and behind Faster write_string_escaped: single-pass block escaping (SSE2, NEON) #2795 on short strings. If there is interest I can open a follow-up with the code and numbers for just that band.

@FranciscoThiesen FranciscoThiesen changed the title Builder and integer-parsing performance: in-place to_json, integer-valued doubles, one-pass escaper, SWAR digits perf: in-place to_json, integer-valued double fast path, one-pass string escaper, SWAR integer parsing Jul 30, 2026
@FranciscoThiesen FranciscoThiesen changed the title perf: in-place to_json, integer-valued double fast path, one-pass string escaper, SWAR integer parsing perf: in-place to_json, integer-valued double fast path, SWAR integer parsing Jul 30, 2026
@fior512

fior512 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Hey @FranciscoThiesen, i am curious about the solution you had for escape chars before #2795; in #2794 i showed that performance gain decreases rapidly with strings >32 bytes, was your solution more reliable against long strings ?

@lemire

lemire commented Jul 30, 2026

Copy link
Copy Markdown
Member

@FranciscoThiesen CI failures. ❤️ Got to love those.

@FranciscoThiesen
FranciscoThiesen force-pushed the francisco/serdes-perf branch 3 times, most recently from 34687dc to 9112546 Compare July 31, 2026 00:16
@FranciscoThiesen

Copy link
Copy Markdown
Member Author

@lemire Fixed. Four independent causes, all ours:

  1. -Weffc++ -Werror: the new raw buf member needed explicit copy-control on string_builder (copies now deleted, moves defaulted).
  2. The new test used the concepts-gated append(string_view) overload, which doesn't exist in C++17 configurations (macOS/MinGW/fuzzing); it now uses escape_and_append_with_quotes directly.
  3. parse_*_swar now fall back to the byte loop on targets with SIMDJSON_SWAR_NUMBER_PARSING == 0: parse_eight_digits_unrolled is not endian-safe there, so this one was a latent big-endian correctness bug (s390x, big-endian ppc64), not just a build issue.
  4. The string-backed builder used dest.data() for mutable access, which is const-only before C++17 and broke every C++11/14 job (g++-13, clang-16, MSVC, UBSan). Now &dest[0].

The full matrix is green as of the latest push.

@FranciscoThiesen

Copy link
Copy Markdown
Member Author

@fior512 Yes, and your >32 B instinct is almost exactly where the crossover sits. I ran a length sweep to check (gcc 16.1, aarch64, 5 alternating co-built rounds, medians; escapes placed randomly at 0.3% to match the twitter-like rate from your #2794 table):

len (bytes) #2795 mine Δ
8 3.7 GB/s 1.3 GB/s −64%
12 5.1 1.5 −71%
16 6.4 6.4 ±0%
24 6.0 3.7 −38%
32 9.1 9.4 +4%
48 10.4 11.4 +10%
64 11.6 13.1 +13%
128 13.2 16.0 +21%
256 13.7 17.8 +29%
512 13.7 18.3 +34%
2048 14.6 18.4 +26%
65536 14.5 18.0 +24%

The 16 B parity and the 24 B dip are the same effect seen from both sides: 16 B is exactly one SIMD chunk for my version, 24 B leaves an 8-byte scalar tail, and your overlapped loads handle both without a tail. On clean input (measured separately at rate 0) it's parity at 16 B, 32 B and from ~512 B up, while at 48-256 B clean #2795 is 2-6% faster. Below 16 B you win outright no matter what. At a denser 1/16 rate mine wins at every length, +22% to +81%.

One caveat on the numbers: the "mine" build also carried unrelated builder changes from my PR. A control run with identical escapers on both sides showed that tree loses 0-7% on this micro from binary layout alone, so the table, if anything, understates my side.

Why does length cancel out? Both escapers spend their steady state in the same loop, which compiles to 13 instructions per clean 16-byte chunk here (ldr q, cmeq x2 + cmhs, orr x2, shrn + fcmp, b.ne, str q, add + cmp + b). So roughly:

cycles/byte ≈ c_clean/16 + (k/n) · c_escape

n cancels, the density k/n survives, and c_escape dwarfs a clean chunk. The real difference between the two implementations is only what happens on a hit. escape_block copies the clean runs between escapes, so bytes that were already sitting in a register get copied again, and dense input turns into many small memcpys. Mine blind-stores the chunk, then consumes the whole mask in registers, repositioning the tail after each escape (vqtbl1q_u8 on NEON, a one-time stack spill on SSE2): one load and one store per 16 input bytes at any density. That's the +13 to +34% band above. The price is shuffle setup on every hit, so it loses slightly at every-byte density and has no answer to your overlapped loads under 16 B, which is why I dropped it when #2795 landed.

Rereading #2794 with this data, I think your masked-load idea and mine are complements rather than competitors. Yours is strongest exactly where the per-string fixed costs live, 8-32 B, which covers the twitter/citm medians of 8-11 B and is why #2795 wins the macro benchmarks. The blind-store bulk path takes over from ~32 B up. A hybrid (#2795's short and clean paths unchanged, with in-register repositioning inside escape_block) should beat both everywhere. Happy to open a follow-up with the code and the sweep harness if there's interest.

@FranciscoThiesen
FranciscoThiesen requested a review from lemire August 3, 2026 20:34
@lemire

lemire commented Aug 3, 2026

Copy link
Copy Markdown
Member

Promising.

@fior512

fior512 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@FranciscoThiesen, sorry for the delay ^^. I am not completely sure i understand the idea you had about write_string_escape, but if you think both can be merged into a single one, i'd be glad to help !!

…lued doubles, SWAR digits

Three independent improvements (details and benchmarks in the PR):

1. to_json(value, std::string&) builds directly into the destination
   string via a new string-backed string_builder mode: no growth-copy
   chain, no final assign copy. Twitter to_json +42% (reused string).
2. Integer-valued doubles (|d| < 1e15, exact integers) route through the
   integer writer with a ".0" suffix, byte-identical to to_chars
   (verified by an exhaustive 2.4M-case identity sweep): +460% on that
   data class. Also fixes NaN/Inf silently serializing as bogus finite
   numbers when SIMDJSON_ENABLE_NAN_INF=0; they now emit null.
3. get_int64/get_uint64 gain a SWAR fast path gated on
   is_made_of_eight_digits_fast (byte-loop fallback on targets with
   SIMDJSON_SWAR_NUMBER_PARSING disabled, where the eight-digit helpers
   are not endian-safe): 9-19 digit integers +49-109%, CITM deserialize
   +5.8%, twitter neutral.

Output is byte-identical to baseline across all benchmarks and fuzz
harnesses (double-format identity sweep, number-parse differential fuzz,
escaper boundary fuzz under ASan).
Dense escape patterns (up to 6x expansion) at every block-boundary length
and offset, built with initial_capacity 1 so every growth request lands on
an exact pos+needed boundary; output byte-compared against a scalar
reference escaper. Guards the single-pass escaper's capacity handling;
passes under AddressSanitizer and compiles in non-concepts (C++17)
configurations.
When a 16-byte block contains escapes, blind-store it and consume the
entire escape mask via escape_block_hot: after each escape expansion the
block's remaining lanes are repositioned with a register shuffle
(vqtbl1q_u8 on NEON, a one-time spill on SSE2) instead of escape_block's
per-run copies. Clean blocks and sub-16-byte strings keep the existing
paths untouched.

Escape-heavy strings gain 32-60% (one escape per 16 bytes, any length);
twitter serialization gains ~6% across builder variants. Sparse escapes
in long strings (one per ~333 bytes) measure 5-11% slower in a synthetic
sweep; the trade-off is documented in the PR. The blind store requires
one vector of slack past the worst-case expansion, so the escape
capacity checks move from 2+6n to 6n+18 (and analogues); the
tight-capacity boundary test exercises the new budgets under ASan.
@FranciscoThiesen FranciscoThiesen changed the title perf: in-place to_json, integer-valued double fast path, SWAR integer parsing perf: in-place to_json, integer-valued double fast path, SWAR integer parsing, in-register escape handling Aug 5, 2026
@FranciscoThiesen

Copy link
Copy Markdown
Member Author

@fior512 went ahead and merged the two approaches here (commit f5d4c4b, the description now has fresh numbers against current master). Your structure stays exactly as is for clean blocks and short strings. When a block actually contains escapes, it now gets blind-stored and the whole mask is consumed in registers, instead of copying the runs between escapes.

Net effect: +32-59% on escape-heavy strings at any length, about +6% on twitter serialization, parity on clean input.

One trade-off I want to be upfront about: long strings with rare escapes (one per ~333 B) come out 5-11% slower in a synthetic sweep. The out-of-line escape handler makes the caller re-materialize its vector constants after each call. I tried four other shapes (inlining the handler, forcing the parent inline, a 32 B length split, a two-phase loop) and each one fixed that band by breaking another, so I kept the one that never loses on real documents.

If you feel like digging into per-block strategy selection together, I have all the sweep data and harnesses ready.

@lemire

lemire commented Aug 12, 2026

Copy link
Copy Markdown
Member

Still on my todo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants