Skip to content

Commit 784cc00

Browse files
committed
json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string()
1 parent a7b265c commit 784cc00

6 files changed

Lines changed: 333 additions & 1 deletion

File tree

NEWS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ PHP NEWS
1919
. Fixed bug GH-23385 (SplDoublyLinkedList::serialize() use-after-free when
2020
__serialize() removes an element). (David Carlier)
2121

22+
- JSON:
23+
. Added a vectorized (SSE2/NEON) fast path to php_json_escape_string()
24+
that bulk-copies runs of bytes which need no escaping, speeding up
25+
json_encode() on ASCII-heavy strings.
26+
2227

2328
10 Sep 2026, PHP 8.6.0beta3
2429

UPGRADING

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,8 @@ PHP 8.6 UPGRADE NOTES
10831083
. Improve performance of encoding arrays and objects.
10841084
. Improved performance of indentation generation in json_encode()
10851085
when using PHP_JSON_PRETTY_PRINT.
1086+
. Added an SSE2/NEON fast path for encoding strings that need no
1087+
escaping, speeding up json_encode() on ASCII-heavy payloads.
10861088

10871089
- Intl:
10881090
. Improved performance of IntlCalendar::getAvailableLocales() and

ext/json/json_encoder.c

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,32 @@
2828
#include "zend_property_hooks.h"
2929
#include "zend_lazy_objects.h"
3030

31+
#include "Zend/zend_simd.h"
32+
#include "Zend/zend_bitset.h" /* zend_ulong_ntz() */
33+
3134
static const char digits[] = "0123456789abcdef";
3235

36+
#ifdef XSSE2
37+
/* Bytes that need escaping: < 0x20, >= 0x80, and the ASCII specials
38+
* " \ / < > & '. Must be kept in sync with the `charmap` bitmap used by
39+
* the scalar loop in php_json_escape_string(). _mm_cmplt_epi8() is a
40+
* signed compare, so "< 0x20" already covers every byte >= 0x80 (negative
41+
* as a signed int8), which is why there's no separate high-bit test. */
42+
static zend_always_inline __m128i php_json_escape_dirty_mask(__m128i chunk)
43+
{
44+
__m128i dirty = _mm_cmplt_epi8(chunk, _mm_set1_epi8(0x20));
45+
46+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('"')));
47+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('\\')));
48+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('/')));
49+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('<')));
50+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('>')));
51+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('&')));
52+
dirty = _mm_or_si128(dirty, _mm_cmpeq_epi8(chunk, _mm_set1_epi8('\'')));
53+
return dirty;
54+
}
55+
#endif
56+
3357
static zend_always_inline bool php_json_check_stack_limit(void)
3458
{
3559
#ifdef ZEND_CHECK_STACK_LIMIT
@@ -379,7 +403,8 @@ zend_result php_json_escape_string(
379403

380404
/* pre-allocate for string length plus 2 quotes */
381405
smart_str_alloc(buf, len+2, 0);
382-
smart_str_appendc(buf, '"');
406+
ZSTR_VAL(buf->s)[ZSTR_LEN(buf->s)] = '"';
407+
ZSTR_LEN(buf->s)++;
383408

384409
pos = 0;
385410

@@ -388,6 +413,58 @@ zend_result php_json_escape_string(
388413
0xffffffff, 0x500080c4, 0x10000000, 0x00000000,
389414
0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff};
390415

416+
#ifdef XSSE2
417+
#if defined(__aarch64__) || defined(_M_ARM64)
418+
while (len >= sizeof(__m128i)) {
419+
if (UNEXPECTED(ZEND_BIT_TEST(charmap, (unsigned char) s[pos]))) {
420+
break;
421+
}
422+
423+
__m128i chunk = _mm_loadu_si128((const __m128i *)(s + pos));
424+
__m128i dirty = php_json_escape_dirty_mask(chunk);
425+
uint8x16_t dirty_u8 = vreinterpretq_u8_s8(dirty);
426+
427+
if (vmaxvq_u8(dirty_u8) == 0) {
428+
pos += sizeof(__m128i);
429+
len -= sizeof(__m128i);
430+
continue;
431+
}
432+
{
433+
static const uint8_t lane_index[16] = {
434+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
435+
uint8x16_t masked_idx = vbslq_u8(dirty_u8, vld1q_u8(lane_index), vdupq_n_u8(0xff));
436+
size_t clean = vminvq_u8(masked_idx);
437+
pos += clean;
438+
len -= clean;
439+
}
440+
break;
441+
}
442+
#else
443+
while (len >= sizeof(__m128i)) {
444+
if (UNEXPECTED(ZEND_BIT_TEST(charmap, (unsigned char) s[pos]))) {
445+
break;
446+
}
447+
448+
__m128i chunk = _mm_loadu_si128((const __m128i *)(s + pos));
449+
__m128i dirty = php_json_escape_dirty_mask(chunk);
450+
451+
int mask = _mm_movemask_epi8(dirty);
452+
if (mask != 0) {
453+
size_t clean = zend_ulong_ntz((zend_ulong) (unsigned int) mask);
454+
pos += clean;
455+
len -= clean;
456+
break;
457+
}
458+
pos += sizeof(__m128i);
459+
len -= sizeof(__m128i);
460+
}
461+
#endif
462+
if (len == 0) {
463+
smart_str_appendl(buf, s, pos);
464+
break;
465+
}
466+
#endif
467+
391468
unsigned int us = (unsigned char)s[pos];
392469
if (EXPECTED(!ZEND_BIT_TEST(charmap, us))) {
393470
pos++;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
--TEST--
2+
json_encode() SSE2 fast-path boundary handling
3+
--FILE--
4+
<?php
5+
/* Regression tests for the SSE2 chunked fast path in
6+
* php_json_escape_string() (ext/json/json_encoder.c). The fast path scans
7+
* 16-byte chunks and resumes scanning right after each escaped byte, so
8+
* exercise lengths and escape/codepoint positions around that boundary. */
9+
10+
function check($label, $actual, $expected) {
11+
if ($actual === $expected) {
12+
echo "$label: OK\n";
13+
} else {
14+
echo "$label: FAIL\n";
15+
var_dump($expected, $actual);
16+
}
17+
}
18+
19+
// 1. Pure clean ASCII at/around the 16-byte chunk boundary.
20+
foreach ([15, 16, 17, 31, 32, 33] as $len) {
21+
$s = str_repeat('a', $len);
22+
check("clean len=$len", json_encode($s), '"' . $s . '"');
23+
}
24+
25+
// 2. A single escapable byte at each offset around the boundary.
26+
foreach ([0, 15, 16, 17] as $off) {
27+
$len = 24;
28+
$s = str_repeat('a', $off) . '"' . str_repeat('a', $len - $off - 1);
29+
$expected = '"' . str_repeat('a', $off) . '\\"' . str_repeat('a', $len - $off - 1) . '"';
30+
check("quote at offset=$off", json_encode($s), $expected);
31+
}
32+
33+
// 3. A 3-byte UTF-8 sequence (EUR SIGN, U+20AC) straddling the boundary.
34+
// Checked both with the default \uXXXX escaping and with
35+
// JSON_UNESCAPED_UNICODE, which appends the raw UTF-8 bytes instead.
36+
foreach ([13, 14, 15, 16] as $off) {
37+
$len = 20;
38+
$s = str_repeat('a', $off) . "\xe2\x82\xac" . str_repeat('a', $len - $off - 3);
39+
$tail = str_repeat('a', $len - $off - 3);
40+
check("3-byte utf8 at offset=$off", json_encode($s),
41+
'"' . str_repeat('a', $off) . "\\u20ac" . $tail . '"');
42+
check("3-byte utf8 at offset=$off, UNESCAPED_UNICODE", json_encode($s, JSON_UNESCAPED_UNICODE),
43+
'"' . str_repeat('a', $off) . '' . $tail . '"');
44+
}
45+
46+
// 4. A 4-byte UTF-8 sequence (surrogate pair, U+1F600) straddling the boundary.
47+
foreach ([12, 13, 14, 15, 16] as $off) {
48+
$len = 20;
49+
$s = str_repeat('a', $off) . "\xf0\x9f\x98\x80" . str_repeat('a', $len - $off - 4);
50+
$tail = str_repeat('a', $len - $off - 4);
51+
check("4-byte utf8 at offset=$off", json_encode($s),
52+
'"' . str_repeat('a', $off) . "\\ud83d\\ude00" . $tail . '"');
53+
check("4-byte utf8 at offset=$off, UNESCAPED_UNICODE", json_encode($s, JSON_UNESCAPED_UNICODE),
54+
'"' . str_repeat('a', $off) . '😀' . $tail . '"');
55+
}
56+
57+
// 5. Invalid UTF-8 straddling the boundary: confirm the checkpoint/rollback
58+
// and each INVALID_UTF8_* option still land correctly after the fast path
59+
// has already appended bytes.
60+
foreach ([14, 15, 16, 17] as $off) {
61+
$len = 20;
62+
$s = str_repeat('a', $off) . "\xb0" . str_repeat('a', $len - $off - 1);
63+
$tail = str_repeat('a', $len - $off - 1);
64+
65+
check("invalid utf8 at offset=$off, no flag", json_encode($s), false);
66+
67+
check("invalid utf8 at offset=$off, IGNORE",
68+
json_encode($s, JSON_INVALID_UTF8_IGNORE),
69+
'"' . str_repeat('a', $off) . $tail . '"');
70+
71+
check("invalid utf8 at offset=$off, SUBSTITUTE",
72+
json_encode($s, JSON_INVALID_UTF8_SUBSTITUTE),
73+
'"' . str_repeat('a', $off) . "\\ufffd" . $tail . '"');
74+
}
75+
?>
76+
--EXPECT--
77+
clean len=15: OK
78+
clean len=16: OK
79+
clean len=17: OK
80+
clean len=31: OK
81+
clean len=32: OK
82+
clean len=33: OK
83+
quote at offset=0: OK
84+
quote at offset=15: OK
85+
quote at offset=16: OK
86+
quote at offset=17: OK
87+
3-byte utf8 at offset=13: OK
88+
3-byte utf8 at offset=13, UNESCAPED_UNICODE: OK
89+
3-byte utf8 at offset=14: OK
90+
3-byte utf8 at offset=14, UNESCAPED_UNICODE: OK
91+
3-byte utf8 at offset=15: OK
92+
3-byte utf8 at offset=15, UNESCAPED_UNICODE: OK
93+
3-byte utf8 at offset=16: OK
94+
3-byte utf8 at offset=16, UNESCAPED_UNICODE: OK
95+
4-byte utf8 at offset=12: OK
96+
4-byte utf8 at offset=12, UNESCAPED_UNICODE: OK
97+
4-byte utf8 at offset=13: OK
98+
4-byte utf8 at offset=13, UNESCAPED_UNICODE: OK
99+
4-byte utf8 at offset=14: OK
100+
4-byte utf8 at offset=14, UNESCAPED_UNICODE: OK
101+
4-byte utf8 at offset=15: OK
102+
4-byte utf8 at offset=15, UNESCAPED_UNICODE: OK
103+
4-byte utf8 at offset=16: OK
104+
4-byte utf8 at offset=16, UNESCAPED_UNICODE: OK
105+
invalid utf8 at offset=14, no flag: OK
106+
invalid utf8 at offset=14, IGNORE: OK
107+
invalid utf8 at offset=14, SUBSTITUTE: OK
108+
invalid utf8 at offset=15, no flag: OK
109+
invalid utf8 at offset=15, IGNORE: OK
110+
invalid utf8 at offset=15, SUBSTITUTE: OK
111+
invalid utf8 at offset=16, no flag: OK
112+
invalid utf8 at offset=16, IGNORE: OK
113+
invalid utf8 at offset=16, SUBSTITUTE: OK
114+
invalid utf8 at offset=17, no flag: OK
115+
invalid utf8 at offset=17, IGNORE: OK
116+
invalid utf8 at offset=17, SUBSTITUTE: OK
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
--TEST--
2+
json_encode()/json_decode() round-trip across many lengths (SSE2 fast path)
3+
--FILE--
4+
<?php
5+
/* The SSE2 fast path in php_json_escape_string() scans fixed 16-byte
6+
* chunks, so a string containing a mix of clean ASCII, escapable ASCII,
7+
* and multi-byte UTF-8 sequences will place its "interesting" bytes at
8+
* every possible offset relative to a chunk boundary as its length
9+
* varies. Round-tripping through json_decode() is a strong, deterministic
10+
* oracle here since the decoder is untouched by this patch. */
11+
12+
$alphabet = [
13+
'a', 'b', 'c', ' ', '"', '\\', '/', '<', '>', '&', '\'',
14+
"\t", "\n", "\r", "\x01",
15+
"\xc3\xa9", // 2-byte UTF-8 (e)
16+
"\xe2\x82\xac", // 3-byte UTF-8 (EUR SIGN)
17+
"\xf0\x9f\x98\x80", // 4-byte UTF-8 (surrogate pair on encode)
18+
];
19+
$na = count($alphabet);
20+
21+
$optionSets = [
22+
0,
23+
JSON_UNESCAPED_SLASHES,
24+
JSON_UNESCAPED_UNICODE,
25+
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT,
26+
];
27+
28+
$failures = [];
29+
for ($count = 0; $count <= 80; $count++) {
30+
$s = '';
31+
for ($i = 0; $i < $count; $i++) {
32+
// Deterministic index (no RNG) so the corpus is reproducible.
33+
$idx = ($count * 31 + $i * 17) % $na;
34+
$s .= $alphabet[$idx];
35+
}
36+
37+
foreach ($optionSets as $opts) {
38+
$encoded = json_encode($s, $opts);
39+
if ($encoded === false) {
40+
$failures[] = "count=$count opts=$opts: encode failed";
41+
continue;
42+
}
43+
$decoded = json_decode($encoded);
44+
if ($decoded !== $s) {
45+
$failures[] = "count=$count opts=$opts: round-trip mismatch";
46+
}
47+
}
48+
}
49+
50+
if ($failures) {
51+
echo implode("\n", $failures), "\n";
52+
} else {
53+
echo "OK\n";
54+
}
55+
?>
56+
--EXPECT--
57+
OK
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
--TEST--
2+
json_encode() option flags on strings long enough to hit the SSE2 fast path
3+
--FILE--
4+
<?php
5+
/* Same option-flag matrix as ext/json/tests/006.phpt and
6+
* json_encode_unescaped_slashes.phpt, but padded past 16 bytes on both
7+
* sides so the SSE2 fast path in php_json_escape_string() actually
8+
* engages before and after the special character (short strings never
9+
* exercise it). */
10+
11+
function check($label, $actual, $expected) {
12+
if ($actual === $expected) {
13+
echo "$label: OK\n";
14+
} else {
15+
echo "$label: FAIL\n";
16+
var_dump($expected, $actual);
17+
}
18+
}
19+
20+
$pad = str_repeat('x', 20);
21+
22+
check('tag default', json_encode($pad . '<foo>' . $pad),
23+
'"' . $pad . '<foo>' . $pad . '"');
24+
check('tag HEX_TAG', json_encode($pad . '<foo>' . $pad, JSON_HEX_TAG),
25+
'"' . $pad . "\\u003Cfoo\\u003E" . $pad . '"');
26+
27+
check('apos default', json_encode($pad . "'bar'" . $pad),
28+
'"' . $pad . "'bar'" . $pad . '"');
29+
check('apos HEX_APOS', json_encode($pad . "'bar'" . $pad, JSON_HEX_APOS),
30+
'"' . $pad . "\\u0027bar\\u0027" . $pad . '"');
31+
32+
check('quot default', json_encode($pad . '"baz"' . $pad),
33+
'"' . $pad . '\"baz\"' . $pad . '"');
34+
check('quot HEX_QUOT', json_encode($pad . '"baz"' . $pad, JSON_HEX_QUOT),
35+
'"' . $pad . "\\u0022baz\\u0022" . $pad . '"');
36+
37+
check('amp default', json_encode($pad . '&blong&' . $pad),
38+
'"' . $pad . '&blong&' . $pad . '"');
39+
check('amp HEX_AMP', json_encode($pad . '&blong&' . $pad, JSON_HEX_AMP),
40+
'"' . $pad . "\\u0026blong\\u0026" . $pad . '"');
41+
42+
check('slash default', json_encode($pad . 'a/b' . $pad),
43+
'"' . $pad . 'a\/b' . $pad . '"');
44+
check('slash UNESCAPED_SLASHES', json_encode($pad . 'a/b' . $pad, JSON_UNESCAPED_SLASHES),
45+
'"' . $pad . 'a/b' . $pad . '"');
46+
47+
check('unicode default', json_encode($pad . "\xc3\xa9" . $pad),
48+
'"' . $pad . "\\u00e9" . $pad . '"');
49+
check('unicode UNESCAPED_UNICODE', json_encode($pad . "\xc3\xa9" . $pad, JSON_UNESCAPED_UNICODE),
50+
'"' . $pad . 'é' . $pad . '"');
51+
52+
check('lineterm default', json_encode($pad . "\xe2\x80\xa8" . $pad),
53+
'"' . $pad . "\\u2028" . $pad . '"');
54+
check('lineterm UNESCAPED_UNICODE', json_encode($pad . "\xe2\x80\xa8" . $pad, JSON_UNESCAPED_UNICODE),
55+
'"' . $pad . "\\u2028" . $pad . '"');
56+
check('lineterm UNESCAPED_UNICODE|UNESCAPED_LINE_TERMINATORS',
57+
json_encode($pad . "\xe2\x80\xa8" . $pad, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS),
58+
'"' . $pad . "\xe2\x80\xa8" . $pad . '"');
59+
?>
60+
--EXPECT--
61+
tag default: OK
62+
tag HEX_TAG: OK
63+
apos default: OK
64+
apos HEX_APOS: OK
65+
quot default: OK
66+
quot HEX_QUOT: OK
67+
amp default: OK
68+
amp HEX_AMP: OK
69+
slash default: OK
70+
slash UNESCAPED_SLASHES: OK
71+
unicode default: OK
72+
unicode UNESCAPED_UNICODE: OK
73+
lineterm default: OK
74+
lineterm UNESCAPED_UNICODE: OK
75+
lineterm UNESCAPED_UNICODE|UNESCAPED_LINE_TERMINATORS: OK

0 commit comments

Comments
 (0)