perf: in-place to_json, integer-valued double fast path, SWAR integer parsing, in-register escape handling - #2799
perf: in-place to_json, integer-valued double fast path, SWAR integer parsing, in-register escape handling#2799FranciscoThiesen wants to merge 3 commits into
Conversation
|
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:
|
ff3e5d0 to
6403122
Compare
|
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 ? |
|
@FranciscoThiesen CI failures. ❤️ Got to love those. |
34687dc to
9112546
Compare
|
@lemire Fixed. Four independent causes, all ours:
The full matrix is green as of the latest push. |
|
@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):
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 ( 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. 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 |
|
Promising. |
|
@FranciscoThiesen, sorry for the delay ^^. I am not completely sure i understand the idea you had about |
…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.
9112546 to
f5d4c4b
Compare
|
@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. |
|
Still on my todo. |
Three independent optimizations to the reflection serializer and on-demand integer parsing. Output is byte-identical to
masterin every benchmark and fuzz case; each change is motivated by a profile and ships with a dedicated correctness harness.benchmark/static_reflect)string_builderto_json(std::string&), reused stringto_json(std::string&), fresh stringto_json(std::string&), reused stringdoc.get<T>())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_builderserialization paths are unchanged (±noise).1.
to_json(value, std::string&)builds in placeProfile of
to_jsonon 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: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:A caller that reuses the string pays zero allocations and zero copies, which is the common production pattern.
to_json_stringbuilds into a local and move-returns. The owned mode is untouched on its hot path (the two new members are cold; a cachedbufpointer keeps codegen branch-free).Caveats: destination must not alias the serialized value (documented); under
-fno-exceptions,std::stringgrowth failure aborts exactly as it would in caller code.2. Integer-valued doubles take the integer writer
doublefields 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":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_charsswitches 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 toto_charsover 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 reachto_charsand emit2.696539702293474e+308, valid JSON with silently wrong data. It now emitsnull. Happy to switch to returning an error if preferred.3. SWAR fast path for
get_int64/get_uint64Profile of citm deserialize (baseline):
stage140%, 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:9/16/19-digit integers: +49% / +101% / +81% in isolation; citm deserialize +5.8%; twitter neutral. On targets that set
SIMDJSON_SWAR_NUMBER_PARSINGto 0 (big-endian, whereparse_eight_digits_unrolledis 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_u8on NEON, a one-time spill on SSE2) instead ofescape_block's per-run copies.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*lento6*len + 18(and analogues at all five call sites);escape_tight_capacity_boundaryexercises exactly those budgets under ASan, alongside #2804's exhaustive escaper test which also passes.Testing. The second commit adds
escape_tight_capacity_boundarytobuilder_string_builder_tests: dense escape patterns at every block-boundary length/offset, built withinitial_capacity = 1so every growth lands on an exactpos+neededboundary, 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.