Skip to content

json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string() - #23675

Open
adapik wants to merge 1 commit into
php:masterfrom
adapik:perf/json-sse2-escape
Open

json: Add SSE2/NEON fast path for clean-string runs in php_json_escape_string()#23675
adapik wants to merge 1 commit into
php:masterfrom
adapik:perf/json-sse2-escape

Conversation

@adapik

@adapik adapik commented Sep 12, 2026

Copy link
Copy Markdown

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 — Make stripslashes() only dependent on SSE2 configuration
  • af112f6 — Use SSE2 instructions for url_encode() (the closest prior art: byte-class scanning + escaping, same shape as this PR)
  • 6e3f3cb — Improve strtr() performance using SSE2 instructions
  • b4cbaab — 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-14500, 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-14500 ARM (Apple M2 Silicon)
clean_ascii 1.47× 1.68× 2.17×
mixed (quotes/apostrophes) 2.10× 4.48× 4.08×
html_heavy 1.02× 1.62× 0.87× (reg)
url_heavy 0.93× (reg) 1.67× 0.99×
non_ascii 0.89× (reg) 1.01× 0.84× (reg)

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_ascii regresses on AMD and ARM but is at parity on Intel; url_heavy on 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-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.

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, 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 (installable as pecl 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.
  • 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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant