Repository navigation
ext/standard: Add php_next_utf8_char_inline() - #24283
ArtUkrainskiy wants to merge 1 commit into
Conversation
ca35c07 to
63792a2
Compare
adapik
left a comment
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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.
|
|
||
| /* 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( |
There was a problem hiding this comment.
This is not a blocker, only my thoughts.
php_next_utf8_char() lives in html.c because it is the UTF-8 branch of get_next_char(), which exists for htmlentities() and its 14 charsets, and it was exported in 2010 only so that utf8_decode() could reuse it. Today three of its four callers (json, xml, utf8_decode()) have nothing to do with HTML entities.
This PR adds a new public declaration to ext/standard/html.h. It is not an ABI change, but it is new source-level API. Since we are creating that surface anyway, perhaps it's a good moment to give it a neutral home (e.g. a small dedicated header) rather than cementing html.h as the place for a generic UTF-8 decoder. php_next_utf8_char() can stay exported from html.c unchanged, so nothing breaks.
json_encode(), utf8_decode() and the xml extension decode one character at a time with php_next_utf8_char(), and every call went through the generic charset decoder of html.c. The new static inline function in html.h decodes valid UTF-8 at the call site and leaves every invalid sequence to php_next_utf8_char(), which is unchanged.
63792a2 to
f331165
Compare
|
@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:
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. |
json_encode(),utf8_decode()and the xml extension decode one character at a time withphp_next_utf8_char(). This addsphp_next_utf8_char_inline(), which decodes valid UTF-8 at the call site and keeps the existing function as the fallback for invalid input and as the exported API.Non-ASCII text in
json_encode()gets faster across all six compiler/CPU configurations tested: Cyrillic takes 0.70–0.91 of master's cycles, CJK 0.68–0.84 and emoji 0.82–0.89. With GCC 13 and 14 on the Intel machine some ASCII-heavy inputs get slower at the same instruction count, from code placement; see Trade-offs.Before.
php_next_utf8_char()lives inhtml.c, so it cannot be inlined into ext/json. Inside it,get_next_char()handles 14 charsets and was not inlined either, so each character paid two calls and aswitchon the charset, and the value, the new position and the status came back through memory: about 43 instructions per non-ASCII character in callgrind (36 inget_next_char(), 7 in the wrapper).After.
php_next_utf8_char_inline()inext/standard/html.hhas the same contract asphp_next_utf8_char(). It decodes ASCII and valid two-, three- and four-byte sequences without a call; every invalid or truncated sequence goes tophp_next_utf8_char(), which is unchanged and still exported.Why the shortcut is exact. A lead byte C2..DF followed by a continuation byte 80..BF is always a valid shortest-form character: C0 and C1 are the only overlong two-byte leads. For three bytes the inline path checks the same two conditions as
get_next_char(): the result is at least U+0800 (not overlong) and outside U+D800..U+DFFF (not a surrogate). For four bytes it takes lead bytes F0..F4 only and checks that the result is in U+10000..U+10FFFF, asget_next_char()does. Any other input runs the old code, so which bytes are rejected or skipped and the returned status stay exactly as before.EXPECTED(c < 0xe0)keeps the two-byte case on the straight path. Without it GCC 13 placed that case behind two extra taken jumps: Cyrillic ran the same instructions but took 20% more cycles.Trade-offs
\u00e9(1.10 and 1.13), and with GCC 13 also 1,000 arrays (1.10), 1,000 objects (1.07) and English text with an emoji every few words (1.05, at 0.92 of the instructions). With clang 18 and 20 and on AMD these inputs are at 0.91–1.02. The generated loop over plain bytes is identical to master's and at the same address (objdump, GCC 13).php_json_escape_string()grows by 487 bytes, sophp_json_encode_zval()andphp_json_encode_array(), which follow it, move by 192 bytes; on the 1,000 arrays branch mispredictions go from 1.0 to 3.6–3.9 million per 3,000 calls, at every hot branch, including code this PR does not touch. Hinting branches in the source moved slowdowns of this kind to other inputs when I tried it on an earlier version. Splitting the scan out of the loop and using a byte table removed them for the two- and three-byte version (see the appendix of the report).get_next_char()always inline, which also speeds up the out-of-linephp_next_utf8_char(). Measured with the two- and three-byte version: on top of ext/standard: Add a fast path for ASCII in htmlspecialchars() #24145 this PR still removes 14–20% of the instructions on non-ASCII text, but with GCC 13 the cycles change less (Cyrillic 0.98, Cyrillic withJSON_UNESCAPED_UNICODE0.79, CJK 0.88 of ext/standard: Add a fast path for ASCII in htmlspecialchars() #24145's cycles). The two merge without conflicts.php_next_utf8_char()and its exported symbol are unchanged;php_next_utf8_char_inline()is new (UPGRADING.INTERNALS).utf8_decode()(deprecated, still shipped) runs 1.7–2.8× fewer instructions on 4 KiB of Latin-1, Cyrillic or ASCII text (GCC 13). The xml extension's decoder uses the same path; not measured.php_json_escape_string()+487,utf8_decode()+384, the xml decoder +425.Results
Relative to master, lower is better. Release builds (
-O2, x86-64,--enable-werror): GCC 13.3, GCC 14.2, clang 18.1 and clang 20.1 on an i7-13700H pinned to one P-core; GCC 15.2 and clang 21.1 on a Ryzen 9 5950X pinned to one core. Cycles and instructions per call fromperf statover N calls minus the same process with 0 calls; cycles are the median of alternating runs from one series per machine, 5 per row on the Intel machine and 15 on AMD, where other load was running on other cores (master against itself: medians within 2%). Instructions are given as a range over the six builds.JSON_UNESCAPED_UNICODEJSON_UNESCAPED_UNICODEJSON_UNESCAPED_UNICODEJSON_UNESCAPED_UNICODEJSON_UNESCAPED_UNICODEAll inputs, with the spread between runs, are in the report linked below.
Verification
Output and
json_last_error()are identical to master with all six compiler and CPU combinations on:stdClassand declared properties;utf8_decode()on the same pairs and triples.ext/json,ext/xmlandext/standard/tests/stringspass with the four Intel builds and on a GCC debug build with ASan and UBSan, where the exhaustive checks above report nothing either. The new testjson_encode_utf8_sequences.phptcovers the edges of the inline path and the fallback (lone continuation bytes, truncated and overlong sequences, surrogates, four-byte edges,F5) under theJSON_INVALID_UTF8_*,JSON_UNESCAPED_*andJSON_PARTIAL_OUTPUT_ON_ERRORflags; it fails if the overlong checks of the two- and three-byte inline paths are removed. Separately, the inline decoder was compared with the UTF-8 case ofget_next_char(), extracted fromhtml.c, on every sequence of one to four bytes with a lead byte 80..FF at every prefix length, at cursor 0 and after one ASCII byte, and on every ASCII byte (4.3 billion calls): no difference in value, cursor or status. The same comparison reports differences when either range check of the four-byte path is removed.Not tested: aarch64, 32-bit and MSVC builds (the change has no width-dependent arithmetic).
Full benchmarks, raw runs and reproduction:
https://github.com/ArtUkrainskiy/php-src-bench/tree/main/reports/json-encode-utf8-inline
Broader context and comparison with the other open json_encode() optimization PRs:
https://artukrainskiy.github.io/posts/json-encode-utf8-autumn-2026/