Repository navigation
Conversation
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.
|
CPU cycles per call vs master, negative is faster. The instructions column is the machine-independent check, the real added work:
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 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.
|
@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 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:
Instructions per call (callgrind,
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 Correctness after the change: Thanks also for checking the interaction with #24283. |
Profiling
json_validate()on string-heavy input showed ~63% of the time inphp_json_scan(): the first string pass (STR_P1) matches every byte as its own re2c token.json_validate()taking almost as long asjson_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 characterphp_json_escape_string()flushes the pending run, decodes the character withphp_next_utf8_char()and appends it on its own.Changes:
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.charmaplets through), and withJSON_UNESCAPED_UNICODEkeep well-formed UTF-8 sequences in the pending run instead of copying them one by one. Validation matchesphp_next_utf8_char()(no overlongs, surrogates or code points above U+10FFFF), and U+2028/U+2029 are only kept whenJSON_UNESCAPED_LINE_TERMINATORSis 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:json_decodejson_validatejson_encodejson_decodejson_encodejson_encode+JSON_UNESCAPED_UNICODEjson_decodejson_encodejson_decodejson_decodeWithout SSE2 (scalar fallback only, e.g. aarch64) the gains are smaller: ASCII decode 1.78x, UTF-8 encode with
JSON_UNESCAPED_UNICODE1.71x, but decoding escape-heavy strings is ~5% slower. A NEON variant of the skip loop could follow if wanted.Testing
ext/json/tests/string_scan_boundaries.phptputs 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.json_decode()calls (4 flag sets) plusjson_validate()on random inputs, and 1 440 000json_encode()calls (12 flag sets), all identical including errors. Also with the SIMD paths disabled.ext/jsontests pass on release and debug builds, with and without the SIMD paths; no valgrind errors.