[php-src] PR #24666: ext/json: Speed up scanning and escaping of strings
| From: | zeynallow | Date: | Sat, 24 Oct 2026 23:15:08 +0000 |
| Subject: | [php-src] PR #24666: ext/json: Speed up scanning and escaping of strings | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39846@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24666
Author: zeynallow
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.