json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() - #23675
Open
adapik wants to merge 1 commit into
Open
json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string()#23675adapik wants to merge 1 commit into
adapik wants to merge 1 commit into
Conversation
adapik
force-pushed
the
perf/json-sse2-escape
branch
from
September 12, 2026 13:40
c6c752c to
784cc00
Compare
adapik
force-pushed
the
perf/json-sse2-escape
branch
from
September 12, 2026 14:22
784cc00 to
dbdfa74
Compare
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
json_encode()walks every string byte-by-byte (or UTF-8-codepoint-by-codepoint) inphp_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, viaZend/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 singlesmart_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:
>= 0x80are 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 scalarZEND_BIT_TESTon the front byte, using the exact samecharmapthe 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.zend_simd.h's generic_mm_movemask_epi8()costs ~7 NEON instructions on aarch64 (no native per-lane-bitmask op exists there). A singleUMAXV(vmaxvq_u8) answers "is anything in this chunk dirty at all" at the same cost as x86's singlepmovmskb; only when something IS dirty does it pay for a select +UMINV(vminvq_u8) to find that byte's position directly.smart_str_alloc(buf, len+2, 0)already guarantees capacity, so writing the opening quote directly instead of throughsmart_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— Makestripslashes()only dependent on SSE2 configurationaf112f6— Use SSE2 instructions forurl_encode()(the closest prior art: byte-class scanning + escaping, same shape as this PR)6e3f3cb— Improvestrtr()performance using SSE2 instructionsb4cbaab— SSE2-basedmb_strlen()for known-valid UTF-8 stringsBenchmarks
Real hardware,
TRIALS=7, same pinned commit/compiler/flags on all three — AMD Ryzen 5 7500F, Intel i5-14500, Apple Silicon (arm64, via Docker/OrbStack, genuine ARM containers, not emulated).±95% CIon every cell is under 2% (usually under 0.5%).Only 4 of 15 cells across all three CPUs are flagged as a regression, all in the 0.84–0.93× range (a 7–16% cost) — down from a much worse starting point (see below).
non_asciiregresses on AMD and ARM but is at parity on Intel;url_heavyon ARM sits flat at 0.99×, not a regression.The optimization path: three iterations, each measured before being kept
After first naive approach i made a series of adjustments for ARM and also added a fast pre-checkIteration 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:
Biggest single move: ARM's
non_asciirecovered 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 inphp_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.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:
Testing
masteracross 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).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).ext/jsonsuite passes on x86 (104/106; the other 2 skip on missing locales, unrelated to this change).--enable-debugconfigs;--enable-debugadditionally cross-checks the NEON verdict against both the scalarcharmapbitmap 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, 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
simdjson_php(installable aspecl install simdjson) is decode-only. A fork,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.Related Work
#17734 — bundles UTF-8 decoding rework, code-layout changes, and an SSE2/SSE4.2 escape fast path into one large PR. This PR is scoped to a single function (php_json_escape_string()) only, making it far smaller to review and land independently of that broader effort.
#22932 — similar starting point (SIMD fast path for long ASCII strings), close to my own first iteration, but x86-only (SSE2 via zend_simd.h, benchmarked on Ryzen 9 5900X only — no aarch64/NEON path at all). This PR targets both x86 (SSE2) and ARM (NEON). When I first benchmarked the naive vectorized approach on ARM, the gains were huge on clean strings — but so was the regression on non_ascii content, which got dramatically worse than on x86. That forced another full round of optimization: an architecture-agnostic pre-check that skips the vector attempt entirely when a chunk is already known to be dirty, plus ARM-specific UMAXV/UMINV tuning to cut the cost of the "anything dirty?" test itself. The result is a PR that holds real gains for ascii-heavy strings across every architecture it targets, not just x86.