[php-src] PR #23675: json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string()

From: 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.

« previous php.git-pulls (#38460) next »