Skip to content

ext/json: Speed up scanning and escaping of strings - #24666

Open
zeynallow wants to merge 2 commits into
php:masterfrom
zeynallow:json-string-fast-paths
Open

zeynallow wants to merge 2 commits into
php:masterfrom
zeynallow:json-string-fast-paths

Conversation

@zeynallow

Copy link
Copy Markdown

Profiling json_validate() on string-heavy input showed ~63% of the time in php_json_scan(): the first string pass (STR_P1) matches every byte as its own re2c token. json_validate() taking almost as long as json_decode() confirms the scan, not value construction, dominates.

On the encoding side, json_encode($v, JSON_UNESCAPED_UNICODE) of non-ASCII text was ~3x slower than of ASCII text: for every non-ASCII character php_json_escape_string() flushes the pending run, decodes the character with php_next_utf8_char() and appends it on its own.

Changes:

  • Scanner: before (re)entering STR_P1, skip the run of printable ASCII bytes other than " and \, 16 bytes at a time with SSE2 (baseline on x86_64) and bytewise otherwise. The re2c rules are unchanged; they are just entered less often.
  • Encoder: skip runs of bytes that are always copied verbatim (the same set the existing charmap lets through), and with JSON_UNESCAPED_UNICODE keep well-formed UTF-8 sequences in the pending run instead of copying them one by one. Validation matches php_next_utf8_char() (no overlongs, surrogates or code points above U+10FFFF), and U+2028/U+2029 are only kept when JSON_UNESCAPED_LINE_TERMINATORS is set. Everything else falls through to the existing code, so output and errors are unchanged.

Results

Release build (-O2), x86_64, median of 5 interleaved runs, 20 000-element payloads:

payload operation before after speedup
ASCII text (11 MB) json_decode 27.07 ms 10.39 ms 2.60x
json_validate 24.94 ms 8.62 ms 2.90x
json_encode 18.91 ms 11.96 ms 1.58x
UTF-8 text (11 MB) json_decode 29.47 ms 19.33 ms 1.52x
json_encode 67.08 ms 59.20 ms 1.13x
json_encode + JSON_UNESCAPED_UNICODE 62.20 ms 30.36 ms 2.05x
strings with escapes (4 MB) json_decode 17.07 ms 13.86 ms 1.23x
json_encode 15.34 ms 10.62 ms 1.44x
short mixed values (3.7 MB) json_decode 18.87 ms 17.92 ms 1.05x
numbers only (1.7 MB) json_decode 16.70 ms 16.84 ms 0.99x (noise)

Without SSE2 (scalar fallback only, e.g. aarch64) the gains are smaller: ASCII decode 1.78x, UTF-8 encode with JSON_UNESCAPED_UNICODE 1.71x, but decoding escape-heavy strings is ~5% slower. A NEON variant of the skip loop could follow if wanted.

Testing

  • New ext/json/tests/string_scan_boundaries.phpt puts each special byte (control characters, invalid UTF-8, quotes, escapes, U+2028, ...) at every position of strings of length 0–48, across 16-byte chunk boundaries, for decode, validate and encode with several flag sets. It passes before and after this change, and caught an off-by-one in an earlier version of the SIMD mask.
  • Differential fuzzing against an unpatched binary: 1 000 000 json_decode() calls (4 flag sets) plus json_validate() on random inputs, and 1 440 000 json_encode() calls (12 flag sets), all identical including errors. Also with the SIMD paths disabled.
  • ext/json tests pass on release and debug builds, with and without the SIMD paths; no valgrind errors.

The first scanner pass matched every byte of a string as a separate re2c
token, and json_encode() copied the pending run each time it met a byte
that is not plain ASCII, then decoded the character with
php_next_utf8_char() and copied it on its own.

- Scanner: before (re)entering the first string pass, skip the run of
  printable ASCII bytes other than quote and backslash, 16 bytes at a
  time with SSE2 and bytewise otherwise.
- Encoder: skip runs of bytes that are always copied verbatim the same
  way, and with JSON_UNESCAPED_UNICODE keep well-formed UTF-8 sequences
  in the pending run. Anything else still takes the existing code path,
  so the output is unchanged.
@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

JSON_UNESCAPED_UNICODE encode is a solid win and fine as it is. Without that flag, and for all json_decode, non-Latin text regresses, and I don't think that's acceptable in this form yet. I measured on real dummyjson responses (products, posts, comments, quotes) with the text fields localized into several languages.

CPU cycles per call vs master, negative is faster. The instructions column is the machine-independent check, the real added work:

call master cycles, GCC GCC clang instructions
json_decode(), Chinese posts, raw UTF-8 1,648,524 +52% +44% +23% to +25%
json_decode(), Japanese posts, raw UTF-8 1,618,415 +47% +41% +23% to +25%
json_decode(), Russian posts, raw UTF-8 1,678,540 +28% +22% +19%
json_encode(), Russian posts 2,397,606 +6% +3% +2% to +15%
json_encode(), English users, nested 2,567,988 +6% +4% −3% to +1%
json_encode(), English products, flat 2,122,081 −4% −4% −12% to −8%
json_decode(), English posts 1,587,349 −28% −37% −22% to −21%
json_encode() UNESCAPED_UNICODE, Russian posts 2,908,962 −52% −59% −67% to −56%

json_decode has no mode flag, so decoding non-Latin JSON just gets slower, and the instruction column shows it is real work (+19% to +25%). Default json_encode regresses too, on the text, not the structure. The English users row is +6% cycles but flat instructions, so that one is code placement, not work. Break-even is around 10-15% non-ASCII characters. The text here is fully non-Latin, so these are an upper bound.

The cause looks like the skip running per character even when the byte is already non-ASCII. It pays for a 16-byte SSE setup with nothing to skip. Bailing out when the first byte already stops the run might help. I tried it (a scalar fast-bail, fix.diff in the report). It cuts raw non-Latin from +28..+52% to +13..+15%, but doesn't remove it and doesn't help the encoder.

Decode is the harder half, since it has no flag to opt out. FWIW #24283 touches the same php_json_escape_string path (it inlines the UTF-8 decode) and merges cleanly with this, I checked.

Repro:

$doc = json_encode(array_fill(0, 2000, "日本語のテキストです"), JSON_UNESCAPED_UNICODE);
for ($i = 0; $i < 10000; $i++) json_decode($doc);

Correctness is solid. I fuzzed it against master across flags and broken UTF-8, output is identical, and it's clean under ASan. Full tables, the real-data suite and the fix are in the report, https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/pr-24666-json-scan

Address review: the string fast paths ran for every character, even when
there was nothing to skip, which made non-Latin text slower.

- Scanner: only try the skip after an ASCII byte. Runs of multibyte
  characters stay in the re2c state machine, which handles them in a
  tight loop. Return early when the next byte already needs the state
  machine.
- Encoder: return early from the ASCII skip when the next byte is not
  copied verbatim, and compile the default (escaping) path without the
  checks that are only needed for JSON_UNESCAPED_UNICODE.
@zeynallow

Copy link
Copy Markdown
Author

@ArtUkrainskiy Thank you for the very thorough review and the reproducible harness, this is exactly the case I missed: my "unicode" payload was mostly-Latin text with diacritics, so dense non-Latin text was never measured. I reproduced your numbers (on my machine json_decode of a fully Cyrillic string was even +685% cycles).

The root cause was as you described, plus one more thing: re2c already handles runs of multibyte characters in a tight loop, and calling the skip after every character broke that loop, so the scalar fast-bail alone could not recover it. I pushed a follow-up commit:

  • Scanner: the skip is only tried after an ASCII byte (UTF8_1); multibyte characters (UTF8_2|UTF8_3|UTF8_4) go straight back to the state machine. It also returns early when the next byte already needs the state machine.
  • Encoder: early return from the ASCII skip, and the escaping loop is now an always-inline helper instantiated twice, so the default (escaping) path is compiled without the checks that are only needed for JSON_UNESCAPED_UNICODE.

Instructions per call (callgrind, zif_json_decode/zif_json_encode only), vs master:

case PR head follow-up
json_decode posts, ru / zh / ja / ar (raw UTF-8) +19.8 / +24.4 / +24.0 / +18.7% −1.8 / −1.3 / −1.2 / −1.7%
json_decode posts, ru / zh (escaped) +7.3 / +9.2% +3.6 / +3.1%
json_decode one 20k-char string, 100% 2-byte / 3-byte +109.8 / +68.7% −10.0 / 0.0%
json_decode posts, en −22.4% −21.5%
json_encode posts, ru / zh / ar +15.0 / +8.9 / +15.7% +3.7 / +1.5 / +3.7%
json_encode UNESCAPED_UNICODE posts, ru / zh −59.8 / −52.4% −65.3 / −54.7%
json_encode posts, en −29.6% −26.7%

Cycles (median of interleaved runs, x86_64, GCC; an A/A control was within ±3%): non-Latin raw decode is now +3% to +11% (was +40% to +77% here), escaped −1% to +8%, default encode of non-Latin +2% to +7%, the fully non-ASCII strings −3% to −19%. English stays at −26% to −34%. So for raw non-Latin decode the instructions are now slightly below master but cycles are still somewhat above it, which I can't explain from instruction counts alone (likely branch prediction / layout around the split rule); I'd be grateful if you could rerun your harness on the new head, since you have GCC and clang on both AMD and Intel.

What is not fixed: your synthetic sweep at ~15–30% non-ASCII with very short ASCII runs (e.g. 2–3 ASCII characters between multibyte ones) is still about +22% instructions for decode and +21% for the default encode. I tried two more variants, a scalar prefix of 8 bytes before the vector loop and an re2c PLAIN+ rule instead of SIMD, and both made the real documents worse, so I left them out. Real localized text seems to sit outside that band (whole words, spaces, digits, URLs), but I agree it is a trade-off, not a free win, and I'm happy to follow your and the maintainers' judgement on whether that is acceptable.

Correctness after the change: ext/json tests on release and debug builds, with and without the SSE2 paths, the boundary test, and the same differential fuzzing against master (1M decode cases over 4 flag sets plus json_validate, 1.44M encode cases over 12 flag sets) are all identical.

Thanks also for checking the interaction with #24283.

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.

2 participants