Skip to content
Artem Ukrainskiy
Go back

json_encode() in php-src, autumn 2026: where the time goes and what is on the table

json_encode() is in almost every PHP request that answers an API call. In autumn 2026 there are five open pull requests in php-src that try to make its string escaping faster, plus one of mine. I measured all of them on the same master, with four compilers and two CPUs, and checked that every one of them produces exactly the same bytes as master. This post is the map: what each change does, where it wins, where it loses, and why the answer depends on the compiler more than I expected.

Contents

Where json_encode() spends its time

Almost all of the work for strings happens in one function, php_json_escape_string(). On a typical API response, 1,000 records of 10 short fields, it takes about half of the instructions of the whole json_encode() call. Three things in it cost the most on master:

How I measured

Source links point to master at bd6c637d071. The changes were tested at these commits:

The measurements of #24283, with raw runs and the commands to reproduce them, are in php-src-bench. The numbers for #23675 and #23682 are in my comments on those pull requests; the first-pass raw data for #17734, #22932 and #22492 is not published yet.

The open pull requests

PRWhat it doesState on 13 October 2026
#23675SSE2/NEON scan of 16 bytes at a timemy measurements posted, no maintainer review yet
#23682256-byte lookup table instead of the bit testreviewed in September, my measurements posted, no reply yet
#17734own UTF-8 decoder and SIMD paththe maintainer asked to split it; the author left it for someone else to pick up
#22932SIMD scan of the clean prefix onlyopen
#22492fast path for keysno activity since July

SIMD (#23675, #22932, #17734)

On long ASCII all three are about 5× faster than master (0.19–0.22 of master’s cycles). On text with escapes spread through it #23675 keeps the vector loop going after each escape (HTML 0.21), while #22932 only skips the prefix before the first special byte and then runs at master’s speed.

The cost is non-ASCII text with default flags. There every byte of every character needs escaping, so the vector path never skips anything and each character also pays the walk over the block’s bitmask. With GCC that makes Cyrillic and CJK 4–18% slower with #23675 (and 17–29% with #17734, measured with GCC 13). With clang, #23675 is at 0.89–0.98 on the same inputs: not because its loop is better there, but because clang’s master is worse, as the next section shows.

The lookup table (#23682)

Replacing the bit test with a 256-byte table removes instructions with every compiler: 25–33% fewer on ASCII. Whether that becomes time depends on the compiler:

cycles vs masterGCC 13GCC 14clang 18clang 20
ASCII, 4 KiB0.790.821.001.02
HTML0.810.871.071.00
Cyrillic1.191.210.961.00

With GCC, Cyrillic gets slower at fewer instructions: branch mispredictions on that input go up about 2.5 times.

Inline UTF-8 decoding (#24283, mine)

php_next_utf8_char_inline() decodes valid UTF-8 at the call site and leaves every invalid sequence to the existing php_next_utf8_char(). The shortcut is exact: C2..DF followed by a continuation byte is always a valid shortest form; for three bytes it checks the same two conditions as the old code (not overlong, not a surrogate), and for four bytes the same range, U+10000..U+10FFFF. With four compilers on the Intel machine and two on a Ryzen 9 5950X, Cyrillic takes 0.70–0.91 of master’s cycles, CJK 0.68–0.84 and emoji 0.82–0.89, and ASCII runs the same instructions as before. The first version of the PR left four-byte characters to the old call after the inline checks, which made emoji 2–13% slower than master; measuring emoji caught it, and the current version decodes them inline too.

master:
  php_json_escape_string()
    └─ php_next_utf8_char()               html.c, exported
         └─ get_next_char(cs_utf_8, ...)  switch over 14 charsets

#24283:
  php_json_escape_string()
    ├─ valid UTF-8 (2, 3 or 4 bytes) ──> decoded inline
    └─ invalid or truncated ──────────> php_next_utf8_char(), as before

The two-byte case is the whole idea in small:

if (EXPECTED(c < 0xe0)) {
	/* C2..DF + continuation byte: always a valid shortest form */
	if (c >= 0xc2 && avail >= 2 && (str[pos + 1] & 0xc0) == 0x80) {
		*cursor = pos + 2;
		*status = SUCCESS;
		return ((c & 0x1f) << 6) | (str[pos + 1] & 0x3f);
	}
}

Two caveats. With GCC 13 and 14 on the Intel machine some inputs take 7–20% more cycles at the same instruction count: a newline every 40 bytes, URLs, ASCII with an occasional escaped é, and with GCC 13 also 1,000 arrays (1.10) and 1,000 objects (1.07). php_json_escape_string() grows by 487 bytes, the functions after it move by 192 bytes, and branch mispredictions triple across the hot code, including code the change does not touch; clang and the AMD machine show nothing of the kind (0.91–1.02), and for the first version the scan split described below removed it. An input with an é exactly every 31 bytes looked worse at first, but that was the branch predictor learning the period, so the tables use é at random positions. And my other PR, #24145 for htmlspecialchars(), makes get_next_char() always inline, which already takes Cyrillic to 0.82 on its own; measured with the first version, the inline decoder still removes 14–20% of the instructions on non-ASCII text on top of it, but with GCC that shows mostly on CJK and on JSON_UNESCAPED_UNICODE.

The compiler is part of the result

The most instructive part of this work was a change that looked obviously right and was not.

Keeping the scan position in a register instead of on the stack, by not passing its address to the decoder, made HTML 0.57–0.60 and a quote every 16 bytes 0.44 of master’s cycles with GCC 13 and 14. With clang the same change did nothing for ASCII, because clang already kept the counter in a register and only stored a copy. With clang 20, the compiler in php-src’s CI, it made HTML 17% and a newline every 40 bytes 42% slower: the loop ended up with two checks for the end of the string per byte.

Fixing that for clang by reshaping the loop broke Cyrillic for clang through branch mispredictions, and fixing that broke ASCII again. The way out was to stop fighting the compiler inside one large function:

Together, with the first version of the inline decoder, cycles relative to master:

GCC 13GCC 14clang 18clang 20
ASCII, 4 KiB0.590.540.530.53
Cyrillic0.650.700.660.61
CJK0.780.730.590.58
a newline every 40 bytes0.390.310.670.63
1,000 arrays of 10 fields0.890.920.970.91
worst of 14 inputs1.001.031.011.05

So the table from #23682 is not just a smaller win: it is the piece that makes the result independent of the compiler, once the scan is a loop of its own.

The same thing showed up from the other side in #23675. Counting instructions per non-ASCII character in php_json_escape_string() itself, master runs 74 with GCC and 93 with clang: clang stores seven registers to the stack before every call to php_next_utf8_char() and loads them back after it. #23675 adds about 9 instructions per character for its bitmask walk with both compilers, but its new loop also happens to stop clang from spilling, so with clang it comes out ahead and with GCC behind. Neither result says much about the SIMD code itself.

Lessons I am keeping:

Next door

How the pieces fit together

The numbers for #24283 are reproducible from the report. If you maintain an application that sends a lot of non-ASCII JSON, I would like to hear how these numbers look on your data.


Share this post: