diff --git a/NEWS b/NEWS index 5182713edfc5..b902bbef1086 100644 --- a/NEWS +++ b/NEWS @@ -19,6 +19,11 @@ PHP NEWS . Fixed bug GH-23385 (SplDoublyLinkedList::serialize() use-after-free when __serialize() removes an element). (David Carlier) +- JSON: + . Added a vectorized (SSE2/NEON) fast path to php_json_escape_string() + that bulk-copies runs of bytes which need no escaping, speeding up + json_encode() on ASCII-heavy strings. + 10 Sep 2026, PHP 8.6.0beta3 diff --git a/UPGRADING b/UPGRADING index 3980faa4294d..7e7cfef7f6e5 100644 --- a/UPGRADING +++ b/UPGRADING @@ -1083,6 +1083,8 @@ PHP 8.6 UPGRADE NOTES . Improve performance of encoding arrays and objects. . Improved performance of indentation generation in json_encode() when using PHP_JSON_PRETTY_PRINT. + . Added an SSE2/NEON fast path for encoding strings that need no + escaping, speeding up json_encode() on ASCII-heavy payloads. - Intl: . Improved performance of IntlCalendar::getAvailableLocales() and diff --git a/ext/json/json_encoder.c b/ext/json/json_encoder.c index b8ae31040c8b..8080483782ca 100644 --- a/ext/json/json_encoder.c +++ b/ext/json/json_encoder.c @@ -27,9 +27,32 @@ #include "zend_enum.h" #include "zend_property_hooks.h" #include "zend_lazy_objects.h" +#include "zend_simd.h" +#include "zend_bitset.h" static const char digits[] = "0123456789abcdef"; +#ifdef XSSE2 +/* Bytes that need escaping: < 0x20, >= 0x80, and the ASCII specials + * " \ / < > & '. Must be kept in sync with the `charmap` bitmap used by + * the scalar loop in php_json_escape_string(). _mm_cmplt_epi8() is a + * signed compare, so "< 0x20" already covers every byte >= 0x80 (negative + * as a signed int8), which is why there's no separate high-bit test. */ +static zend_always_inline __m128i php_json_escape_dirty_mask(__m128i chunk) +{ + __m128i dirty = _mm_cmplt_epi8(chunk, _mm_set1_epi8(0x20)); + + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('"'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('\\'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('/'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('<'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('>'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('&'))); + dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('\''))); + return dirty; +} +#endif + static zend_always_inline bool php_json_check_stack_limit(void) { #ifdef ZEND_CHECK_STACK_LIMIT @@ -379,7 +402,8 @@ zend_result php_json_escape_string( /* pre-allocate for string length plus 2 quotes */ smart_str_alloc(buf, len+2, 0); - smart_str_appendc(buf, '"'); + ZSTR_VAL(buf->s)[ZSTR_LEN(buf->s)] = '"'; + ZSTR_LEN(buf->s)++; pos = 0; @@ -388,6 +412,58 @@ zend_result php_json_escape_string( 0xffffffff, 0x500080c4, 0x10000000, 0x00000000, 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff}; +#ifdef XSSE2 +#if defined(__aarch64__) || defined(_M_ARM64) + while (len >= sizeof(__m128i)) { + if (UNEXPECTED(ZEND_BIT_TEST(charmap, (unsigned char) s[pos]))) { + break; + } + + __m128i chunk = _mm_loadu_si128((const __m128i *)(s + pos)); + __m128i dirty = php_json_escape_dirty_mask(chunk); + uint8x16_t dirty_u8 = vreinterpretq_u8_s8(dirty); + + if (vmaxvq_u8(dirty_u8) == 0) { + pos += sizeof(__m128i); + len -= sizeof(__m128i); + continue; + } + { + static const uint8_t lane_index[16] = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}; + uint8x16_t masked_idx = vbslq_u8(dirty_u8, vld1q_u8(lane_index), vdupq_n_u8(0xff)); + size_t clean = vminvq_u8(masked_idx); + pos += clean; + len -= clean; + } + break; + } +#else + while (len >= sizeof(__m128i)) { + if (UNEXPECTED(ZEND_BIT_TEST(charmap, (unsigned char) s[pos]))) { + break; + } + + __m128i chunk = _mm_loadu_si128((const __m128i *)(s + pos)); + __m128i dirty = php_json_escape_dirty_mask(chunk); + + int mask = _mm_movemask_epi8(dirty); + if (mask != 0) { + size_t clean = zend_ulong_ntz((zend_ulong) (unsigned int) mask); + pos += clean; + len -= clean; + break; + } + pos += sizeof(__m128i); + len -= sizeof(__m128i); + } +#endif + if (len == 0) { + smart_str_appendl(buf, s, pos); + break; + } +#endif + unsigned int us = (unsigned char)s[pos]; if (EXPECTED(!ZEND_BIT_TEST(charmap, us))) { pos++; diff --git a/ext/json/tests/json_encode_sse2_boundary.phpt b/ext/json/tests/json_encode_sse2_boundary.phpt new file mode 100644 index 000000000000..bb5b396cc7f2 --- /dev/null +++ b/ext/json/tests/json_encode_sse2_boundary.phpt @@ -0,0 +1,116 @@ +--TEST-- +json_encode() SSE2 fast-path boundary handling +--FILE-- + +--EXPECT-- +clean len=15: OK +clean len=16: OK +clean len=17: OK +clean len=31: OK +clean len=32: OK +clean len=33: OK +quote at offset=0: OK +quote at offset=15: OK +quote at offset=16: OK +quote at offset=17: OK +3-byte utf8 at offset=13: OK +3-byte utf8 at offset=13, UNESCAPED_UNICODE: OK +3-byte utf8 at offset=14: OK +3-byte utf8 at offset=14, UNESCAPED_UNICODE: OK +3-byte utf8 at offset=15: OK +3-byte utf8 at offset=15, UNESCAPED_UNICODE: OK +3-byte utf8 at offset=16: OK +3-byte utf8 at offset=16, UNESCAPED_UNICODE: OK +4-byte utf8 at offset=12: OK +4-byte utf8 at offset=12, UNESCAPED_UNICODE: OK +4-byte utf8 at offset=13: OK +4-byte utf8 at offset=13, UNESCAPED_UNICODE: OK +4-byte utf8 at offset=14: OK +4-byte utf8 at offset=14, UNESCAPED_UNICODE: OK +4-byte utf8 at offset=15: OK +4-byte utf8 at offset=15, UNESCAPED_UNICODE: OK +4-byte utf8 at offset=16: OK +4-byte utf8 at offset=16, UNESCAPED_UNICODE: OK +invalid utf8 at offset=14, no flag: OK +invalid utf8 at offset=14, IGNORE: OK +invalid utf8 at offset=14, SUBSTITUTE: OK +invalid utf8 at offset=15, no flag: OK +invalid utf8 at offset=15, IGNORE: OK +invalid utf8 at offset=15, SUBSTITUTE: OK +invalid utf8 at offset=16, no flag: OK +invalid utf8 at offset=16, IGNORE: OK +invalid utf8 at offset=16, SUBSTITUTE: OK +invalid utf8 at offset=17, no flag: OK +invalid utf8 at offset=17, IGNORE: OK +invalid utf8 at offset=17, SUBSTITUTE: OK diff --git a/ext/json/tests/json_encode_sse2_fuzz.phpt b/ext/json/tests/json_encode_sse2_fuzz.phpt new file mode 100644 index 000000000000..2e2b780b17c1 --- /dev/null +++ b/ext/json/tests/json_encode_sse2_fuzz.phpt @@ -0,0 +1,57 @@ +--TEST-- +json_encode()/json_decode() round-trip across many lengths (SSE2 fast path) +--FILE-- +', '&', '\'', + "\t", "\n", "\r", "\x01", + "\xc3\xa9", // 2-byte UTF-8 (e) + "\xe2\x82\xac", // 3-byte UTF-8 (EUR SIGN) + "\xf0\x9f\x98\x80", // 4-byte UTF-8 (surrogate pair on encode) +]; +$na = count($alphabet); + +$optionSets = [ + 0, + JSON_UNESCAPED_SLASHES, + JSON_UNESCAPED_UNICODE, + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT, +]; + +$failures = []; +for ($count = 0; $count <= 80; $count++) { + $s = ''; + for ($i = 0; $i < $count; $i++) { + // Deterministic index (no RNG) so the corpus is reproducible. + $idx = ($count * 31 + $i * 17) % $na; + $s .= $alphabet[$idx]; + } + + foreach ($optionSets as $opts) { + $encoded = json_encode($s, $opts); + if ($encoded === false) { + $failures[] = "count=$count opts=$opts: encode failed"; + continue; + } + $decoded = json_decode($encoded); + if ($decoded !== $s) { + $failures[] = "count=$count opts=$opts: round-trip mismatch"; + } + } +} + +if ($failures) { + echo implode("\n", $failures), "\n"; +} else { + echo "OK\n"; +} +?> +--EXPECT-- +OK diff --git a/ext/json/tests/json_encode_sse2_options.phpt b/ext/json/tests/json_encode_sse2_options.phpt new file mode 100644 index 000000000000..1fd962287075 --- /dev/null +++ b/ext/json/tests/json_encode_sse2_options.phpt @@ -0,0 +1,75 @@ +--TEST-- +json_encode() option flags on strings long enough to hit the SSE2 fast path +--FILE-- +' . $pad), + '"' . $pad . '' . $pad . '"'); +check('tag HEX_TAG', json_encode($pad . '' . $pad, JSON_HEX_TAG), + '"' . $pad . "\\u003Cfoo\\u003E" . $pad . '"'); + +check('apos default', json_encode($pad . "'bar'" . $pad), + '"' . $pad . "'bar'" . $pad . '"'); +check('apos HEX_APOS', json_encode($pad . "'bar'" . $pad, JSON_HEX_APOS), + '"' . $pad . "\\u0027bar\\u0027" . $pad . '"'); + +check('quot default', json_encode($pad . '"baz"' . $pad), + '"' . $pad . '\"baz\"' . $pad . '"'); +check('quot HEX_QUOT', json_encode($pad . '"baz"' . $pad, JSON_HEX_QUOT), + '"' . $pad . "\\u0022baz\\u0022" . $pad . '"'); + +check('amp default', json_encode($pad . '&blong&' . $pad), + '"' . $pad . '&blong&' . $pad . '"'); +check('amp HEX_AMP', json_encode($pad . '&blong&' . $pad, JSON_HEX_AMP), + '"' . $pad . "\\u0026blong\\u0026" . $pad . '"'); + +check('slash default', json_encode($pad . 'a/b' . $pad), + '"' . $pad . 'a\/b' . $pad . '"'); +check('slash UNESCAPED_SLASHES', json_encode($pad . 'a/b' . $pad, JSON_UNESCAPED_SLASHES), + '"' . $pad . 'a/b' . $pad . '"'); + +check('unicode default', json_encode($pad . "\xc3\xa9" . $pad), + '"' . $pad . "\\u00e9" . $pad . '"'); +check('unicode UNESCAPED_UNICODE', json_encode($pad . "\xc3\xa9" . $pad, JSON_UNESCAPED_UNICODE), + '"' . $pad . 'é' . $pad . '"'); + +check('lineterm default', json_encode($pad . "\xe2\x80\xa8" . $pad), + '"' . $pad . "\\u2028" . $pad . '"'); +check('lineterm UNESCAPED_UNICODE', json_encode($pad . "\xe2\x80\xa8" . $pad, JSON_UNESCAPED_UNICODE), + '"' . $pad . "\\u2028" . $pad . '"'); +check('lineterm UNESCAPED_UNICODE|UNESCAPED_LINE_TERMINATORS', + json_encode($pad . "\xe2\x80\xa8" . $pad, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS), + '"' . $pad . "\xe2\x80\xa8" . $pad . '"'); +?> +--EXPECT-- +tag default: OK +tag HEX_TAG: OK +apos default: OK +apos HEX_APOS: OK +quot default: OK +quot HEX_QUOT: OK +amp default: OK +amp HEX_AMP: OK +slash default: OK +slash UNESCAPED_SLASHES: OK +unicode default: OK +unicode UNESCAPED_UNICODE: OK +lineterm default: OK +lineterm UNESCAPED_UNICODE: OK +lineterm UNESCAPED_UNICODE|UNESCAPED_LINE_TERMINATORS: OK