Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 77 additions & 1 deletion ext/json/json_encoder.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand All @@ -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++;
Expand Down
116 changes: 116 additions & 0 deletions ext/json/tests/json_encode_sse2_boundary.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
--TEST--
json_encode() SSE2 fast-path boundary handling
--FILE--
<?php
/* Regression tests for the SSE2 chunked fast path in
* php_json_escape_string() (ext/json/json_encoder.c). The fast path scans
* 16-byte chunks and resumes scanning right after each escaped byte, so
* exercise lengths and escape/codepoint positions around that boundary. */

function check($label, $actual, $expected) {
if ($actual === $expected) {
echo "$label: OK\n";
} else {
echo "$label: FAIL\n";
var_dump($expected, $actual);
}
}

// 1. Pure clean ASCII at/around the 16-byte chunk boundary.
foreach ([15, 16, 17, 31, 32, 33] as $len) {
$s = str_repeat('a', $len);
check("clean len=$len", json_encode($s), '"' . $s . '"');
}

// 2. A single escapable byte at each offset around the boundary.
foreach ([0, 15, 16, 17] as $off) {
$len = 24;
$s = str_repeat('a', $off) . '"' . str_repeat('a', $len - $off - 1);
$expected = '"' . str_repeat('a', $off) . '\\"' . str_repeat('a', $len - $off - 1) . '"';
check("quote at offset=$off", json_encode($s), $expected);
}

// 3. A 3-byte UTF-8 sequence (EUR SIGN, U+20AC) straddling the boundary.
// Checked both with the default \uXXXX escaping and with
// JSON_UNESCAPED_UNICODE, which appends the raw UTF-8 bytes instead.
foreach ([13, 14, 15, 16] as $off) {
$len = 20;
$s = str_repeat('a', $off) . "\xe2\x82\xac" . str_repeat('a', $len - $off - 3);
$tail = str_repeat('a', $len - $off - 3);
check("3-byte utf8 at offset=$off", json_encode($s),
'"' . str_repeat('a', $off) . "\\u20ac" . $tail . '"');
check("3-byte utf8 at offset=$off, UNESCAPED_UNICODE", json_encode($s, JSON_UNESCAPED_UNICODE),
'"' . str_repeat('a', $off) . '€' . $tail . '"');
}

// 4. A 4-byte UTF-8 sequence (surrogate pair, U+1F600) straddling the boundary.
foreach ([12, 13, 14, 15, 16] as $off) {
$len = 20;
$s = str_repeat('a', $off) . "\xf0\x9f\x98\x80" . str_repeat('a', $len - $off - 4);
$tail = str_repeat('a', $len - $off - 4);
check("4-byte utf8 at offset=$off", json_encode($s),
'"' . str_repeat('a', $off) . "\\ud83d\\ude00" . $tail . '"');
check("4-byte utf8 at offset=$off, UNESCAPED_UNICODE", json_encode($s, JSON_UNESCAPED_UNICODE),
'"' . str_repeat('a', $off) . '😀' . $tail . '"');
}

// 5. Invalid UTF-8 straddling the boundary: confirm the checkpoint/rollback
// and each INVALID_UTF8_* option still land correctly after the fast path
// has already appended bytes.
foreach ([14, 15, 16, 17] as $off) {
$len = 20;
$s = str_repeat('a', $off) . "\xb0" . str_repeat('a', $len - $off - 1);
$tail = str_repeat('a', $len - $off - 1);

check("invalid utf8 at offset=$off, no flag", json_encode($s), false);

check("invalid utf8 at offset=$off, IGNORE",
json_encode($s, JSON_INVALID_UTF8_IGNORE),
'"' . str_repeat('a', $off) . $tail . '"');

check("invalid utf8 at offset=$off, SUBSTITUTE",
json_encode($s, JSON_INVALID_UTF8_SUBSTITUTE),
'"' . str_repeat('a', $off) . "\\ufffd" . $tail . '"');
}
?>
--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
57 changes: 57 additions & 0 deletions ext/json/tests/json_encode_sse2_fuzz.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
--TEST--
json_encode()/json_decode() round-trip across many lengths (SSE2 fast path)
--FILE--
<?php
/* The SSE2 fast path in php_json_escape_string() scans fixed 16-byte
* chunks, so a string containing a mix of clean ASCII, escapable ASCII,
* and multi-byte UTF-8 sequences will place its "interesting" bytes at
* every possible offset relative to a chunk boundary as its length
* varies. Round-tripping through json_decode() is a strong, deterministic
* oracle here since the decoder is untouched by this patch. */

$alphabet = [
'a', 'b', 'c', ' ', '"', '\\', '/', '<', '>', '&', '\'',
"\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
75 changes: 75 additions & 0 deletions ext/json/tests/json_encode_sse2_options.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
--TEST--
json_encode() option flags on strings long enough to hit the SSE2 fast path
--FILE--
<?php
/* Same option-flag matrix as ext/json/tests/006.phpt and
* json_encode_unescaped_slashes.phpt, but padded past 16 bytes on both
* sides so the SSE2 fast path in php_json_escape_string() actually
* engages before and after the special character (short strings never
* exercise it). */

function check($label, $actual, $expected) {
if ($actual === $expected) {
echo "$label: OK\n";
} else {
echo "$label: FAIL\n";
var_dump($expected, $actual);
}
}

$pad = str_repeat('x', 20);

check('tag default', json_encode($pad . '<foo>' . $pad),
'"' . $pad . '<foo>' . $pad . '"');
check('tag HEX_TAG', json_encode($pad . '<foo>' . $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
Loading