Skip to content

ext/json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() - #23675

Open
adapik wants to merge 2 commits into
php:masterfrom
adapik:perf/json-sse2-escape
Open

adapik wants to merge 2 commits into
php:masterfrom
adapik:perf/json-sse2-escape

Conversation

@adapik

@adapik adapik commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

json_encode() walks every string byte-by-byte in php_json_escape_string(), even for the common case of plain ASCII needing no escaping. This adds a vectorized (SSE2/NEON) fast path that scans 16 bytes at a time, finds every character that needs escaping in one pass, and bulk-copies the clean runs in between. No output or behavior change — purely a speed improvement, with wins across clean ASCII, HTML-heavy, and URL-heavy content and no meaningful regression anywhere measured.

Solution

On a 16-byte chunk, the fast path finds every character that needs escaping in one vector comparison and handles all of them before moving to the next chunk, rather than dropping into a slower per-byte loop as soon as it hits one. It also only compares against characters actually being escaped under the active encoder options (so plain </>/&/' don't cost anything when the matching flags aren't set), skips the vector setup for strings shorter than one chunk, and uses one shared implementation across x86 and ARM.

Benchmarks

Speedup range across small/medium/large corpus sizes, per CPU, TRIALS=7:

Corpus Intel i5-14500 Apple M2 AMD Ryzen 5 7500F
clean_ascii 1.24× – 1.63× 1.13× – 1.22× 1.17× – 1.36×
html_heavy 2.43× – 3.60× 1.91× – 1.95× 1.63× – 2.23×
mixed 2.73× – 4.66× 1.55× – 2.21× 1.54× – 2.08×
url_heavy 1.62× – 2.10× 1.02× – 1.02× 0.99× – 1.18×
non_ascii 1.04× – 1.06× 1.04× – 1.04× 0.97× – 1.00×

(Only one cell out of 42 measured regresses (non_ascii, AMD, 0.97×) — within noise. Everything else is flat-to-positive.

Testing

  • Output byte-identical to master across all corpora, 15 encoder-flag combinations, and boundary lengths around the 16-byte chunk edge.
  • Correctness fuzzed against a reference scalar implementation (1.65M+ runs) before benchmarking.
  • New tests: json_encode_sse2_boundary.phpt, json_encode_sse2_options.phpt, json_encode_sse2_fuzz.phpt. Full ext/json suite passes.

Why this doesn't need an RFC

Per CONTRIBUTING.md, RFCs are for feature requests and behavior-changing proposals. This changes no API, no ini directive, and no observable output — only speed.

Alternatives

  • A PECL/third-party extension (simdjson_php, decode-only; a fork adds encoding) exists but isn't in core, re-implements the encoder, and can drift from php-src's exact escaping semantics.
  • A different wire format (igbinary, MessagePack) avoids escaping overhead but isn't a substitute when JSON is required for interop.

Related Work

@adapik
adapik requested a review from bukka as a code owner September 12, 2026 13:37
@adapik
adapik force-pushed the perf/json-sse2-escape branch 2 times, most recently from 784cc00 to dbdfa74 Compare September 12, 2026 14:22
@adapik adapik changed the title json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() ext/json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() Sep 14, 2026
@adapik
adapik marked this pull request as draft September 18, 2026 10:59
@adapik
adapik force-pushed the perf/json-sse2-escape branch from dbdfa74 to 27d9c1c Compare September 18, 2026 11:04
@adapik
adapik marked this pull request as ready for review September 18, 2026 11:21
@ArtUkrainskiy

Copy link
Copy Markdown
Contributor

I measured this on current master (bd6c637) with four compilers, the same way as #23682.

Correctness. On current master it needs one change to build: ZEND_STATIC_ASSERT() was replaced by static_assert() in c5e45a1. With that, output and json_last_error() are byte-identical to master on every pair of bytes and every three-byte sequence with an E0..EF lead (six flag sets), every byte value at every position of ASCII strings 1–24 bytes long, literal keys, and 200,000 random strings under 11 flag sets, with GCC 13.3, GCC 14.2, clang 18.1 and clang 20.1.

Numbers. Cycles / instructions per json_encode() call relative to master; perf stat over N calls minus the same process with 0 calls, median of 5 alternating runs, release builds, i7-13700H pinned to one P-core:

4 KiB string unless noted GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
ASCII, 16 bytes 0.87 / 0.85 0.95 / 0.86 0.94 / 0.80 0.96 / 0.78
ASCII, 256 bytes 0.41 / 0.39 0.45 / 0.41 0.43 / 0.38 0.42 / 0.37
ASCII 0.22 / 0.22 0.25 / 0.25 0.21 / 0.21 0.22 / 0.21
HTML 0.21 / 0.36 0.23 / 0.38 0.35 / 0.33 0.34 / 0.33
URLs 0.53 / 0.74 0.57 / 0.75 0.74 / 0.67 0.76 / 0.66
a quote every 16 bytes 0.29 / 0.47 0.27 / 0.49 0.60 / 0.45 0.66 / 0.45
a newline every 40 bytes 0.24 / 0.34 0.20 / 0.36 0.41 / 0.33 0.41 / 0.33
ASCII with an é every 31 bytes on average 0.25 / 0.47 0.25 / 0.49 0.45 / 0.44 0.46 / 0.44
a quote in every byte 1.19 / 1.10 1.15 / 1.07 1.01 / 0.95 0.88 / 0.93
Cyrillic 1.13 / 1.08 1.18 / 1.07 0.91 / 0.90 0.98 / 0.90
Cyrillic, JSON_UNESCAPED_UNICODE 0.89 / 1.04 1.07 / 1.03 1.00 / 0.90 0.95 / 0.91
CJK 1.10 / 1.04 1.04 / 1.02 0.89 / 0.88 0.94 / 0.88
French 0.77 / 0.85 0.80 / 0.85 0.79 / 0.74 0.84 / 0.74
1,000 arrays of 10 fields 0.93 / 0.94 0.92 / 0.92 0.95 / 0.88 0.94 / 0.87
1,000 objects 0.91 / 0.94 0.93 / 0.92 0.98 / 0.88 0.99 / 0.87
  • Strings of 256 bytes and more that are mostly ASCII are 1.3–5× faster with every compiler; 16-byte strings 4–13%.
  • Cyrillic, CJK and a string of quotes are 4–19% slower with GCC, at 2–10% more instructions than master. With clang the same inputs run 5–12% fewer instructions and are at 0.88–1.01 in cycles.
  • With default flags every byte of a non-ASCII character needs escaping, so on that text the vector path skips nothing and each character also pays the walk over the chunk's bitmask (tzcnt, the compare with chunk_left, the shift, the counters).
  • The difference between compilers is on master's side. On the Cyrillic input, php_json_escape_string() itself (without php_next_utf8_char()) runs 74 instructions per non-ASCII character on master (all of its work on that input divided by the number of such characters) with GCC 13 and 14, and 93 with clang 18 and 20: clang stores seven registers to the stack before every call to php_next_utf8_char() and loads them back after it, GCC does not. With this PR it is 83 with GCC and 79 with clang: the bitmask walk adds about 9 per character, and with clang the new loop also no longer spills around the call (callgrind, per-instruction counts).

With #24283 on top. #24283 decodes valid two- and three-byte UTF-8 inline in this function. Merged onto this PR (the one call in php_json_escape_dirty_char() switched to php_next_utf8_char_inline()), output is identical under the same checks, and cycles / instructions relative to master are:

GCC 13.3 GCC 14.2 clang 18.1 clang 20.1
ASCII 0.22 / 0.22 0.27 / 0.25 0.21 / 0.21 0.21 / 0.21
HTML 0.20 / 0.36 0.23 / 0.38 0.35 / 0.34 0.38 / 0.34
URLs 0.55 / 0.73 0.55 / 0.75 0.72 / 0.70 0.77 / 0.69
a quote in every byte 1.18 / 1.10 1.10 / 1.07 0.92 / 0.91 0.85 / 0.88
Cyrillic 0.85 / 0.82 0.87 / 0.81 0.68 / 0.67 0.66 / 0.66
Cyrillic, JSON_UNESCAPED_UNICODE 0.67 / 0.74 0.83 / 0.74 0.80 / 0.69 0.74 / 0.69
CJK 0.84 / 0.79 0.78 / 0.77 0.58 / 0.66 0.62 / 0.64
French 0.66 / 0.69 0.60 / 0.70 0.64 / 0.62 0.69 / 0.61
ASCII with an é every 31 bytes on average 0.21 / 0.40 0.21 / 0.42 0.39 / 0.43 0.43 / 0.38
1,000 arrays of 10 fields 0.90 / 0.94 0.98 / 0.92 0.93 / 0.88 0.92 / 0.87

So together nothing non-ASCII stays slower than master; the one row left above 1.00 is a string made only of quotes with GCC (1.10–1.18). Merging the two needs only that one call changed.

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