Skip to content

ext/standard: Add php_next_utf8_char_fast() - #24283

Open
ArtUkrainskiy wants to merge 1 commit into
php:masterfrom
ArtUkrainskiy:json-utf8-2byte
Open

ArtUkrainskiy wants to merge 1 commit into
php:masterfrom
ArtUkrainskiy:json-utf8-2byte

Conversation

@ArtUkrainskiy

@ArtUkrainskiy ArtUkrainskiy commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

json_encode(), utf8_decode() and the xml extension decode one character at a time through the exported php_next_utf8_char(). This adds php_next_utf8_char_fast() in html.h to decode valid UTF-8 at the call site. Invalid or truncated input falls back to the existing exported function, preserving its error handling and ABI.

get_next_char() is already always inlined into the exported decoder. This still avoids the exported call for valid input. The measurements compare the change with master 97335aa86f7, including #24145.

Fresh release builds use the official Docker-image compiler and linker flags, plus -g and --enable-werror. No explicit alignment flags are used. Five alternating passes per case on Intel and fifteen on AMD. Unescaped Unicode rows use JSON_UNESCAPED_UNICODE. Negative = fewer cycles (faster).

call master, CPU cycles per call (GCC 13.3) GCC 13.3 GCC 14.2 clang 18.1 clang 20.1 GCC 15.2 (AMD) clang 21.1 (AMD)
Cyrillic, 4 KiB 35,063 −19% −28% +12% −22% −22% −14%
Cyrillic, unescaped Unicode, 4 KiB 56,511 −15% −29% −21% −25% −17% −22%
CJK, 4 KiB 22,791 −7% −11% −20% −19% −24% −21%
CJK, unescaped Unicode, 4 KiB 25,056 −11% −11% −13% −15% −18% −20%
Emoji, 4 KiB 25,041 −10% −9% −14% −9% −7% −11%
Emoji, unescaped Unicode, 4 KiB 18,933 −17% −16% −14% −14% −22% −18%
French, 4 KiB 24,467 −4% −3% −1% −17% −15% −8%
ASCII with random-position e acute, 4 KiB 23,056 −5% −7% −3% −10% −5% −4%
ASCII with periodic e acute, unescaped Unicode, 4 KiB 21,786 −17% +24% −6% −4% −6% −3%
ASCII, 16 bytes 135 +7% −4% 0% +1% +1% +1%
ASCII, 4 KiB 8,114 +6% 0% 0% 0% +6% +1%
ASCII, 64 KiB 134,588 +3% 0% 0% 0% +5% 0%
HTML, 4 KiB 28,450 +5% −1% −1% 0% +4% 0%
URLs, 4 KiB 30,128 +8% +1% −1% 0% −1% −1%
Quotes only, 4 KiB 25,683 0% 0% −1% +1% +12% 0%
A newline every 40 bytes, 4 KiB 22,313 −33% −21% 0% 0% +7% 0%
1,000 arrays of 10 fields 888,668 +16% −6% +3% −4% −1% 0%
1,000 objects of 10 properties 832,933 +13% +2% +1% +4% 0% 0%

The full report includes instructions, all 26 inputs, pass ranges and regressions. Maximum absolute A/A median deviation: Intel 1.31%, AMD 0.23%. Differences comparable to the controls should not be treated as established gains or regressions. The earlier alignment-profile figures are archived as diagnostics.

The existing exhaustive byte-sequence checks and fuzz generators match master on all six Docker-profile builds. JSON, XML and string tests pass on GCC 13, clang 20 and GCC 15. I haven't rerun sanitizers or tested aarch64, 32-bit or MSVC builds.

Full results, exact flags and reproduction: https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/json-encode-utf8-inline

@adapik adapik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through the implementation and I find the approach reasonable: the inline path only takes sequences that are valid by construction, and everything else falls back to the unchanged php_next_utf8_char(), so the error semantics can't diverge. I also ran it through my own benchmarks and the results are solid.

Regarding overlap with my other PRs (24283 and 24282):

  • The dirty table is a small, self-contained change to the clean/dirty classification of a byte. I think it stands on its own and combines well with this PR, since this PR changes what happens after a byte is classified as dirty (non-ASCII), not the classification itself.
  • I don't see any overlap with the SIMD work either. The two optimizations operate on different branches of php_json_escape_string() (the SIMD one on runs of clean ASCII, this one on the non-ASCII decode), so they complement each other.

Depending on which one is merged first, I'm happy to rebase mine, that's not a problem.

@@ -0,0 +1,217 @@
--TEST--
json_encode() with valid and invalid UTF-8 sequences around the two-, three- and four-byte boundaries

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test only covers json_encode(). Since the validation logic is now duplicated between html.c and html.h, a small phpt exercising the same boundary sequences through utf8_decode() would stop the two copies from drifting apart silently.

Comment thread ext/standard/html.h Resolved
@ArtUkrainskiy

Copy link
Copy Markdown
Contributor Author

@adapik Thanks for reviewing it and for running it through your benchmarks.

Test: I added ext/standard/tests/strings/utf8_decode_sequences.phpt with the same boundary sequences through utf8_decode(), plus overlong encodings of é, which utf8_decode() makes visible as é instead of "?". The expected output comes from master. To check that the two tests catch drift between the copies, I mutated the inline decoder one condition at a time:

  • accepting C0/C1 leads, three- and four-byte overlong forms, skipping the third-byte check, advancing the cursor by 2 instead of 3: both tests fail;
  • accepting surrogates or code points above U+10FFFF: only the json_encode() test fails, because utf8_decode() turns both a valid character above U+00FF and an invalid sequence into a single "?".

Header: I put the function in html.h next to php_next_utf8_char() because of the earlier review of #18126, where the suggestion was to optimize the existing decoder rather than add a new one elsewhere (#18126 (comment)). A small dedicated header, with php_next_utf8_char() staying exported from html.c, is a change of a few lines here.

@bukka, as the owner of ext/standard and ext/json: do you prefer the inline decoder in html.h, as it is now, or in a separate header (for example ext/standard/php_utf8.h)? I'll update the PR either way.

@ArtUkrainskiy

Copy link
Copy Markdown
Contributor Author

@bukka @iluuu1994 could you take a look when you get a chance? The only open point is the function name, happy to rename. It adds php_next_utf8_char_inline() in html.h and uses it in json_encode. Stacks cleanly with #24666.

@bukka bukka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this look reasonable. Just naming could be better as noted. I merged #24145 so get_next_char is now always inlined which slightly reduced the initial motivation for this. It should still improve things but it would be good to re-measure it. It should also update the commit message which is no longer completely true...

Comment thread ext/standard/html.h Outdated

/* Same contract as php_next_utf8_char(). Decodes valid UTF-8 inline; invalid input goes
* to php_next_utf8_char(). */
static zend_always_inline unsigned int php_next_utf8_char_inline(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this name - Can you rename it to php_next_utf8_char_fast or something more consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to php_next_utf8_char_fast(), remeasured and updated description

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remaining ASCII/escaping regressions may be addressed by the scan changes in #23675. I'll check the combined results once this PR lands.

Decode valid UTF-8 at the call site in json_encode(), utf8_decode() and
the xml extension. Invalid or truncated input falls back to the unchanged
exported php_next_utf8_char(), preserving its error handling and ABI.

get_next_char() is already always inlined into php_next_utf8_char(), but
callers in other translation units still pay for the exported call.
@ArtUkrainskiy ArtUkrainskiy changed the title ext/standard: Add php_next_utf8_char_inline() ext/standard: Add php_next_utf8_char_fast() Oct 31, 2026
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.

3 participants