[php-src] PR #23675: json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string()
| From: | adapik | Date: | Sat, 12 Sep 2026 13:38:00 +0000 |
| Subject: | [php-src] PR #23675: json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38460@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23675
Author: adapik
# Summary
json_encode() walks every string byte-by-byte (or UTF-8-codepoint-by-codepoint) in
php_json_escape_string(), even for the overwhelmingly common case: a run of plain ASCII
that needs no escaping at all. This adds a vectorized (SSE2 on x86-64, NEON on aarch64, via
Zend/zend_simd.h) fast path that scans 16 bytes at a time and bulk-copies clean runs,
falling back to the existing scalar loop (unchanged) for anything it can't prove clean.
**Who benefits:** anyone serializing large volumes of JSON dominated by IDs/UUIDs, timestamps,
enum-like strings, or other clean-ASCII fields — REST API responses, structured logging, queue
message producers, cache payloads. This is the majority shape of real-world API JSON.
No behavior change: output is byte-for-byte identical to before on every corpus and flag combination
tested, on all three CPU architectures below.
## Solution
Scan each 16-byte chunk for "any byte that needs escaping" (control chars, >=
0x80, and the ASCII specials " \ / < > & '); if the whole
chunk is clean, skip past it and keep scanning — resuming after every escaped byte, not just
accelerating a prefix — then flush the whole clean run in a single
smart_str_appendl(). The scalar loop and all existing UTF-8/escaping-option logic are
untouched.
Three refinements beyond the base algorithm, each found by measuring on real hardware and chasing
down what the numbers said, not assumed:
1. **A cheap pre-check, before touching any vector register.** Bytes >= 0x80 are
*always* dirty (they need UTF-8 decoding), so a Cyrillic/Arabic/CJK string wastes a full 16-byte
vector-compare attempt on literally every codepoint, for zero possible payoff — and the same
happens whenever a tag or slash sits at the front of a chunk (<div>,
/api/..., </tag><br/>). A single scalar
ZEND_BIT_TEST on the front byte, using the exact same charmap the scalar
loop already checks, skips the vector attempt entirely when it's already known to fail —
matching the scalar loop's own per-byte cost instead of adding a vector tax on top of it.
Architecture-agnostic; helps every CPU below.
2. **An aarch64-specific cheaper "is anything dirty" test.**
zend_simd.h's generic _mm_movemask_epi8() costs ~7 NEON instructions
on aarch64 (no native per-lane-bitmask op exists there). A single UMAXV
(vmaxvq_u8) answers "is anything in this chunk dirty at all" at the same cost
as x86's single pmovmskb; only when something IS dirty does it pay for a select +
UMINV (vminvq_u8) to find that byte's position directly.
3. A small, verified-in-isolation-but-below-noise cleanup: the initial smart_str_alloc(buf,
len+2, 0) already guarantees capacity, so writing the opening quote directly instead of
through smart_str_appendc() skips one redundant capacity re-check.
**Precedent.** Vectorizing hot string-processing functions is well-established practice in this
codebase, not a new approach:
-
[2b55dee](https://github.com/php/php-src/commit/2b55dee4dccb695b7ae13be08efab6b147c698d3)
— Make stripslashes() only dependent on SSE2 configuration
-
[af112f6](https://github.com/php/php-src/commit/af112f65720b4e8c0e6a86ae69bddfbd36bee1e6)
— Use SSE2 instructions for url_encode() (the closest prior art: byte-class scanning
+ escaping, same shape as this PR)
-
[6e3f3cb](https://github.com/php/php-src/commit/6e3f3cbaee6321ad2ad7983bde33590f322674dd)
— Improve strtr() performance using SSE2 instructions
-
[b4cbaab](https://github.com/php/php-src/commit/b4cbaabd9b1bed40e8f5c29e43012da1fe6f221d)
— SSE2-based mb_strlen() for known-valid UTF-8 strings
- and others
## Benchmarks
Real hardware, TRIALS=7, same pinned commit/compiler/flags on all three — AMD Ryzen 5
7500F, Intel i5-1135G7, Apple Silicon (arm64, via Docker/OrbStack, genuine ARM containers, not
emulated). ±95% CI on every cell is under 2% (usually under 0.5%).
| Corpus (medium, ~25-55KB) | AMD Ryzen 7500F | Intel i5-1135G7 | ARM (Apple M2 Silicon) |
|---|---:|---:|---:|
| clean_ascii | **1.47×** | **1.88×** | **2.17×** |
| mixed (quotes/apostrophes) | **2.10×** | **3.21×** | **4.08×** |
| html_heavy | **1.02×** | **1.52×** | 0.87× (reg) |
| url_heavy | 0.93× (reg) | **1.42×** | 0.99× |
| non_ascii | 0.89× (reg) | 0.95× | 0.84× (reg) |
Only 3 of 15 cells across all three CPUs are still below parity, all in the 0.84–0.93× range (a
7–16% cost) — down from a much worse starting point (see below). non_ascii is the
one corpus type with a regression on every CPU, though now a small one everywhere.
<details>
<summary>The optimization path: three iterations, each measured before being
kept</summary>
After first naive approach i made a series of adjustments for ARM and also added a fast pre-check
**Iteration 1 (base fast path):** clean_ascii/mixed win everywhere (1.2–1.8× on first
measurement). html_heavy/url_heavy/non_ascii regress, and not uniformly — Intel actually *won* on
html/url from the start, while AMD was flat-to-mildly-worse and ARM regressed hard (down to
0.44–0.79×). Real microarchitectural divergence on the same source and flags, not noise.
**Iteration 2 (ARM-specific UMAXV/UMINV):** targeted at why ARM's regression was worse than
x86's — _mm_movemask_epi8()'s NEON emulation cost, paid on every chunk
attempt. Recovered 15–31% of ARM's regression, at zero cost to the wins. Didn't touch
x86 or fix the root cause (repeated wasted attempts).
**Iteration 3 (the pre-check, architecture-agnostic):** targeted the root cause directly — every
wasted vector attempt, on any CPU. Measured before and after on all three:
| Corpus | AMD before→after | Intel before→after | ARM before→after |
|---|---:|---:|---:|
| html_heavy_medium | 0.88→**1.02×** | 1.33→**1.52×** | 0.67→0.87× |
| url_heavy_medium | 0.86→0.93× | 1.21→**1.42×** | 0.87→0.99× |
| non_ascii_medium | 0.80→0.89× | 0.77→0.95× | 0.52→**0.84×** |
Biggest single move: ARM's non_ascii recovered 62% of its regression (0.52× →
0.84×) — the CPU where the wasted-attempt cost was highest to begin with benefited the most from
removing it, which is the result you'd want if the diagnosis was right. html_heavy flipped from
a regression to a genuine win on both AMD and Intel.
**What I checked and didn't ship:** profiled whether
php_json_escape_string()'s own setup code (the fixed cost paid by every call,
dominant for short strings — array-of-many-short-non-ASCII-strings payloads) had more room. Found
one real redundancy (see Solution #3), fixed it, and it's below the noise floor in isolation.
The actual floor there is hash-table iteration and zval type dispatch in
php_json_encode_array() — core Zend Engine machinery shared by every PHP function
that iterates arrays, not something specific to JSON escaping, and out of scope to touch in this PR.
</details>
### Reproducing these numbers
Self-contained script — builds both the unpatched and patched binary in Docker, runs correctness +
7-trial timing on any machine with Docker, no other setup:
```
curl -fsSL https://raw.githubusercontent.com/adapik/php-src/perf/json-sse2-escape-scripts/scripts/json/cross_cpu_bench.sh
-o cross_cpu_bench.sh
chmod +x cross_cpu_bench.sh
./cross_cpu_bench.sh
```
## Testing
- Output verified byte-identical to unpatched master across 30 corpora × 2 flag
combinations, on all three CPUs above, plus boundary lengths 0/1/15/16/17/31/32/33 bytes (clean and
dirty-last-byte variants).
- New tests in ext/json/tests/: json_encode_sse2_boundary.phpt
(escape/UTF-8/invalid-UTF-8 bytes at every offset around the 16-byte chunk edge),
json_encode_sse2_options.phpt (every encoder option flag on strings long enough to hit
the fast path), json_encode_sse2_fuzz.phpt (encode/decode round-trip across string
lengths 0–80).
- Full ext/json suite passes on x86 (104/106; the other 2 skip on missing locales,
unrelated to this change).
- The aarch64-specific branch cross-compiles cleanly in both release and --enable-debug
configs; --enable-debug additionally cross-checks the NEON verdict against both the
scalar charmap bitmap and the generic movemask+ntz result, byte-by-byte, on every chunk
— confirmed via disassembly to compile away entirely (zero instructions) in release builds.
## Why this doesn't need an RFC
Per [CONTRIBUTING.md](https://github.com/php/php-src/blob/master/CONTRIBUTING.md#feature-requests),
RFCs are for feature requests and large/behavior-changing proposals. This is neither: it's an
internal implementation change to an existing function with no new API, no new ini directive, and no
observable behavior change (output is identical; the only externally visible effect is speed).
Recent JSON performance work, and the SIMD string-function precedent above, have consistently landed
the same way, as plain PRs without an RFC.
## Alternatives
- **A PECL extension instead of a core change.**
[simdjson_php](https://github.com/crazyxman/simdjson_php) (installable as pecl
install simdjson) is decode-only. A fork,
[simdjson-plus-php-ext](https://github.com/awesomized/simdjson-plus-php-ext), does add
accelerated JSON *encoding* as a drop-in replacement, so an extension-based path does exist. But
it's a third-party fork of a PECL extension most installs don't have, it re-implements the
whole encoder rather than reusing php-src's, and it can drift from php-src's exact
escaping/option semantics over time. Fixing the hot path in core benefits every installation with
zero new dependency and guarantees identical behavior by construction, rather than relying on a
second encoder to stay in sync.
- **A different wire format** (igbinary, MessagePack) for internal/non-interop serialization avoids
escaping overhead entirely, but isn't a substitute when JSON output is actually required for
interop, which is the case this PR targets.